One Notification Tap Opened the Screen Twice
You tap a push notification on your phone when the application is closed. The app cold-starts, boots its splash screen, and opens the product details screen. But when you press the back button expecting to see your home feed, you land on the exact same product details screen again. You have to press back a second time to finally exit.
Consume each incoming URL once. A cold start can deliver it twice.
This is Day 13 of Ship Native, a production story dissecting a real issue we resolved in active mobile applications. In Day 12: Why Customers Came Back as Guests, and What Logout Must Remove, we covered store rehydration and logout architecture. Today we look at mobile intent routing: why cold starts fire two delivery paths simultaneously, how to build a consume-once guard, and why notification taps must funnel through the exact same pipeline.
The companion video features schematic diagrams, synthetic narration, and timed bilingual subtitles. The code examples below represent production patterns tested in active consumer apps.
The symptom: duplicate screen push
In our e-commerce application Riya, customer reports began surfacing after a release that improved push notification routing. Users who opened order status alerts on closed apps found that pressing back did not return them to the home screen. Instead, the screen flickered and showed the same order details screen.
The navigation stack had pushed the exact same route twice in succession:
[3] ProductDetails (id: 42) <- ACTIVE SCREEN
[2] ProductDetails (id: 42) <- DUPLICATE SCREEN
[1] HomeScreen (root)
Tapping back popped screen number three off the stack, revealing screen number two underneath.
Cold start versus warm start delivery
To understand why this happens, look at how React Native receives incoming links from the operating system.
When an app is already open in the background (a warm start), the operating system delivers the intent via native platform hooks: onNewIntent on Android, or application:openURL:options: on iOS. The React Native bridge forwards this event to JavaScript through Linking.addEventListener('url', callback).
When an app starts cold from an unopened process, two things happen at once:
- The launch intent is preserved by the native runtime and exposed via
Linking.getInitialURL(), which returns a Promise resolving to the launch URL. - As the JavaScript environment initializes, native lifecycle events also fire
Linking.addEventListener('url', callback).
// The dangerous dual-listener pattern:
export function setupBuggyDeepLinks(navigate: (url: string) => void): void {
// Cold start delivery:
Linking.getInitialURL().then((url) => {
if (url) {
navigate(url); // Push number one!
}
});
// Runtime event listener:
Linking.addEventListener('url', (event) => {
navigate(event.url); // Push number two on cold start!
});
}
Because both callbacks run independently within milliseconds of each other, your router receives two separate navigation requests for the exact same URL.
The consume-once guard
In Riya commit 218f423, we resolved this duplicate push by introducing an explicit consumption guard.
The guard enforces two safety checks:
hasProcessedInitialUrl: EnsuresLinking.getInitialURL()is evaluated only once per cold-start application session.- A timestamped deduplication window: Ignores identical URLs delivered within a 1500ms window, protecting against rapid dual delivery.
class DeepLinkHandler {
private static instance: DeepLinkHandler | null = null;
private hasProcessedInitialUrl = false;
private lastHandledUrl: string | null = null;
private lastHandledAt = 0;
private pendingDeepLink: string | null = null;
public initialize(): () => void {
// Only process initial URL once per cold start session
if (!this.hasProcessedInitialUrl) {
Linking.getInitialURL().then((url) => {
if (url) {
this.hasProcessedInitialUrl = true;
this.handleDeepLink(url);
}
});
}
const subscription = Linking.addEventListener('url', (event: { url: string }) => {
this.handleDeepLink(event.url);
});
return () => {
subscription.remove();
};
}
public handleDeepLink(url: string): void {
if (!url) return;
// Deduplication window: drop identical URL within 1500ms
const now = Date.now();
if (this.lastHandledUrl === url && now - this.lastHandledAt < 1500) {
return;
}
this.lastHandledUrl = url;
this.lastHandledAt = now;
this.routeToTarget(url);
}
}
Waiting for the navigation container
A second common bug during cold starts is trying to navigate before the router is ready.
When an app boots cold from a link, Linking.getInitialURL() often resolves before the root NavigationContainer finishes mounting. If code calls navigationRef.navigate() immediately, React Navigation either throws an unhandled error or silently drops the intent.
To solve this, queue the consumed link in a pending variable and flush it only when navigationRef.isReady() returns true:
public setAppReady(): void {
this.isAppReady = true;
if (this.pendingDeepLink && this.navigationRef?.isReady()) {
const target = this.pendingDeepLink;
this.pendingDeepLink = null;
this.navigateToTarget(target);
}
}
Call setAppReady() inside the onReady prop of your root NavigationContainer.
Unifying push notification taps
Push notifications are often built by different engineers than deep links. A common architectural flaw is writing a separate navigation handler for notification taps.
When a user taps an incoming push notification on Android, the operating system can pass the payload through the launch intent extras, while the notification library also triggers a notification response event.
Never maintain separate routing logic for notifications. Extract the URL from the notification payload and funnel it through your central DeepLinkHandler:
export function handleNotificationTap(payload: NotificationPayload): void {
const url = payload.data?.deepLink ?? payload.data?.url;
if (url) {
DeepLinkHandler.getInstance().handleDeepLink(url);
}
}
Because notifications route through the same handler, they automatically benefit from the consume-once guard and container readiness queue.
App Links and associated domains
In Riya commit 74c9e28, we updated our production configuration to support Android App Links and iOS Universal Links. Universal links bypass the browser and launch the application directly.
To enable direct opening:
- Host
assetlinks.jsonathttps://www.example.com/.well-known/assetlinks.jsonon your web domain, signed with your app fingerprint. - Host
apple-app-site-associationathttps://www.example.com/.well-known/apple-app-site-associationwith your Apple Team ID and bundle ID. - Configure
Associated Domainsin Xcode (applinks:www.example.com) and<intent-filter android:autoVerify="true">inAndroidManifest.xml.
Whether a user clicks a custom scheme (shipnative://product/42), a universal link (https://www.example.com/product/42), or a push notification tap, the underlying intent reaches the exact same React Native handler.
Architectural limitations
The deduplication window (1500ms) drops identical URLs occurring back-to-back. If a user intentionally taps two different links in rapid succession, both are processed because their URLs differ. However, if a user somehow taps the exact same link twice within 1.5 seconds, the second tap is ignored. In mobile UX, this trade-off is preferable to pushing duplicate screens.
Verification steps
To verify cold-start deep link delivery in your development environment:
- Terminate the application process completely:
- iOS:
xcrun simctl terminate booted com.shipnative.app - Android:
adb shell am force-stop com.shipnative.app
- iOS:
- Trigger a cold-start deep link from your terminal:
- iOS:
xcrun simctl openurl booted 'shipnative://product/42' - Android:
adb shell am start -W -a android.intent.action.VIEW -d 'shipnative://product/42' com.shipnative.app
- iOS:
- Observe the rendered screen and inspect the React Navigation stack depth.
- Verify that the product screen appears exactly once.
- Tap the back button: confirm that you return directly to the home screen, never to an identical product screen.
Series roadmap
- Previous: Day 12: Why Customers Came Back as Guests, and What Logout Must Remove
- Next: Day 14: Offline, API Down, or Slow: Show Saved Data and the Right Message
Comments