Push notification deep links across three app states

Storyie Engineering Team
6 min read

How Storyie routes FCM taps through foreground, background, and cold start — and why a setTimeout was the wrong fix for Expo Router readiness races.

A push notification is only useful if the tap lands somewhere intentional. In Storyie that destination depends on the payload — today's timeline for a diary reminder, a diary slug for an "on this day" memory, /inbox with a deliveryId for a letter. Getting the route right is easy. Getting the timing right is not, because "the user tapped the notification" is not one event: it is three, and each races a different part of app startup.

We send those notifications from SST Cron and write-time jobs (covered in Push notifications in Expo with Firebase and SST). This post is the client half: how NotificationContext routes a tap when the app is foreground, background, or not running at all — and why we threw away a one-second setTimeout.

TL;DR

  • Three OS entry points, one navigator. Foreground uses onMessage + an in-app Alert; background uses onNotificationOpenedApp; cold start uses getInitialNotification. All three eventually call the same handleNotificationNavigation.
  • Cold start is a readiness race, not a delay problem. Storing the payload and waiting for useRootNavigationState().key beat a fixed 1000ms timer that was sometimes too early and sometimes too late.
  • Invalid deep-link params must not be a silent no-op. Failed router.push falls back to router.replace("/") with an error log.
  • Logout cancels pending cold-start navigation so a deferred deep link cannot fight the auth redirect.
  • Badge clears on mount, on AppStateactive, and on open — the icon should not keep a count the user has already acknowledged.

App state

FCM API

When we navigate

Foreground

messaging().onMessage

After the user taps View on an Alert

Background (warm)

onNotificationOpenedApp

Immediately on tap

Cold start (quit)

getInitialNotification

After rootNavigationState.key exists

Three failure modes for one tap

Before the fix (#573 / #580), the same product gesture — "open the thing this notification is about" — failed in three different ways depending on process state:

  1. Foreground: the system banner never appears (or appears inconsistently). Without an in-app affordance, the payload arrives and nothing visible happens.
  2. Background: navigation usually works, but a bad slug or unknown type could make router.push throw — and the tap felt dead.
  3. Cold start: we used to setTimeout(..., 1000) after getInitialNotification resolved, then push. On a warm device that flashed home then jumped; under splash + font + session restore it could still push before Expo Router was ready.

The interesting bug was (3). A magic delay looks like "waiting for init," but init is not a constant. Storyie's root layout already gates the UI on fonts, session, and biometric capability checks (isReady in apps/expo/app/_layout.tsx), and NotificationProvider sits above that themed tree. The navigation stack the deep link needs may not exist when the FCM promise resolves — even a second later on a slow cold start, or much earlier on a fast one.

So we stopped guessing wall-clock time and waited for a signal the router itself exposes.

Foreground: show something, then share the navigator

When the app is already open, Firebase delivers via onMessage. Storyie does not invent a second routing table for that path — it shows a cancelable Alert and reuses the shared navigator when the user chooses View:

// apps/expo/context/NotificationContext.tsx (excerpt)
function showForegroundNotification(remoteMessage: FirebaseMessagingTypes.RemoteMessage): void {
  const { notification, data } = remoteMessage;
  if (!notification) return;

  Alert.alert(
    notification.title || "Storyie",
    notification.body || "",
    [
      { text: "OK", style: "cancel" },
      {
        text: "View",
        onPress: () => handleNotificationNavigation(data as Record<string, string>),
      },
    ],
    { cancelable: true }
  );
}

Data-only messages (no notification block) are ignored here — there is nothing to show. Destination mapping lives in one switch on data.type (diary_reminder, content_limit, on_this_day, reaction, publish_digest, letter_delivery, default → home). Foreground, warm, and cold start only differ in when that function runs.

Background: navigate immediately, fail loudly

A warm tap is the happy path. The process is alive, Expo Router is mounted, and onNotificationOpenedApp fires with the RemoteMessage. We clear the badge and navigate on the spot:

// apps/expo/context/NotificationContext.tsx (excerpt)
const handleNotificationOpened = useCallback(
  (remoteMessage: FirebaseMessagingTypes.RemoteMessage) => {
    clearBadge();
    handleNotificationNavigation(remoteMessage.data as Record<string, string>);
  },
  []
);

The reliability work here is not timing — it is the failure mode. handleNotificationNavigation wraps the type switch so a thrown router.push becomes a logged fallback to home instead of an unhandled rejection the user experiences as "nothing happened":

// apps/expo/context/NotificationContext.tsx (excerpt)
function handleNotificationNavigation(data: Record<string, string> | undefined): void {
  if (!data) return;
  const { type } = data;
  try {
    navigateForNotificationType(type, data);
  } catch (error) {
    logger.error("[Notification] Navigation failed, falling back to home:", error);
    try {
      router.replace("/");
    } catch (fallbackError) {
      logger.error("[Notification] Fallback navigation failed:", fallbackError);
    }
  }
}

Missing slugs are handled inside the switch (reaction without slug/; letter without delivery_id/inbox without params). The try/catch covers the cases the switch cannot — malformed params that make the router throw.

Cold start: pending payload until navigation is ready

Cold start is where the three-state model earns its keep. getInitialNotification() is a promise: it may resolve while the splash is still up and the Stack inside ThemedApp has not mounted. Navigating then is racing the tree.

The old approach deferred with a timer. The current one stores the data and lets a second effect run the navigation once readiness and session both allow it:

// apps/expo/context/NotificationContext.tsx (excerpt)
const rootNavigationState = useRootNavigationState();
const isNavigationReady = Boolean(rootNavigationState?.key);
const [pendingColdStartData, setPendingColdStartData] = useState<Record<string, string> | null>(
  null
);

// inside the session-gated effect:
messaging()
  .getInitialNotification()
  .then((remoteMessage) => {
    if (cancelled || !remoteMessage) return;
    clearBadge();
    setPendingColdStartData((remoteMessage.data as Record<string, string>) ?? null);
  });

useEffect(() => {
  if (!session) {
    setPendingColdStartData(null);
    return;
  }
  if (!isNavigationReady || !pendingColdStartData) return;
  handleNotificationNavigation(pendingColdStartData);
  setPendingColdStartData(null);
}, [session, isNavigationReady, pendingColdStartData]);

Three details matter more than the happy path:

  • rootNavigationState?.key as the gate. When the key appears, Expo Router considers the root state ready. If it is already present when the promise resolves, the effect navigates on the next render — no artificial wait. If not, we idle until it is.
  • cancelled on unmount. If the provider unmounts before the promise settles, we drop the result. Otherwise a late resolve would call setState (or worse, navigate) on a dead tree.
  • Session clears pending data. Logout while a cold-start payload is waiting must discard it. Otherwise the deep link fires into a login redirect and you get two navigations fighting (#580).

Provider placement in _layout.tsx is intentional: NotificationProvider wraps QueryProvider and the themed stack, so listeners attach as soon as a session exists, while the readiness gate still protects pushes until the router key exists.

The behavior is pinned in apps/expo/context/__tests__/NotificationContext.test.tsx: cold start navigates when ready, defers when useRootNavigationState is undefined, warm taps navigate immediately, invalid params fall back to home, post-unmount resolves are ignored, and logout discards pending data. Races like this are miserable to reproduce by hand; the suite is the contract.

Badge: clear when the user has seen the app

Badge count is not deep linking, but it shares the same lifecycle. A user who opens the app — whether from a notification or by tapping the icon — should not keep a red count that implies unread work. Storyie clears via Notifications.setBadgeCountAsync(0) on provider mount, on every AppState transition to active, and again when a notification is opened from background or cold start.

Failures to clear are logged and swallowed. A badge API error must not break navigation; the reverse is also true — navigation must not depend on badge success.

Takeaways

  • Treat notification opens as three entry points that must converge on one navigator, not three copies of the route map.
  • Prefer a real readiness signal (useRootNavigationState().key) over a wall-clock delay when cold-start deep links race Expo Router.
  • Make failed deep links loud and recoverable (log + home) instead of silent.
  • Cancel deferred navigation on logout — auth transitions are another race layer.
  • Encode the races in tests; you will not catch cold-start timing bugs in a manual QA pass.

Server-side delivery, timezone Cron, and token lifecycle live in Push notifications in Expo with Firebase and SST. The same "survive process death" mindset shows up on the data path in Designing an Offline Mutation Queue on SQLite and Soft Deletes in a Local-First Mobile Database.

Available on iOS & Android

Download on the App StoreGet it on Google Play