Mobile · Navigation

The notification that opened the wrong screen

Sending a push notification is easy. Having the tap land on the right screen, from any app state, on both platforms, is where the week goes.

Two problems that look like one

Deep linking and push routing get treated as one feature because they end at the same place — the user lands on a specific screen. They fail for completely different reasons.

Deep linking is a platform trust problem: convincing the OS that your app is entitled to handle a URL. Push routing is a state problem: knowing where to navigate when the payload arrives, given an app that might not be running.

Deep links: the platform has to believe you

Both platforms require you to prove ownership of the domain, and both do it by fetching a file from your server:

  • iOS — an association file at a fixed well-known path, served over HTTPS, with the correct content type, no redirects, and your team and bundle identifiers inside. The OS fetches it at install time. If it's wrong, links open Safari and nothing tells you why.
  • Android — intent filters in the manifest plus a matching assets-links file, with verification tied to your signing certificate fingerprint. Same failure mode: an unverified link opens the browser.

The debugging experience is genuinely poor. There's no error — the link simply behaves as if your app doesn't exist. My rule became: verify the file is being served correctly, from the production domain, with the right headers, before touching any app code. Most of the time the app side was already right and the file was the problem.

A deep link that opens the browser is not a routing bug. It's the OS telling you it doesn't believe you own the domain.

The shared-link case that actually matters

Someone shares a profile link. The recipient may or may not have the app. The right behaviour is different for each, and getting it wrong loses you an install:

  • App installed — open the app on that profile.
  • App not installed — open a web view of the same content, with an install prompt.

The failure we shipped and had to fix was the shared link opening the desktop site on a phone that had the app. Every share was routing users away from the product they'd already installed.

Push routing: it depends what the app was doing

A notification payload has to survive three app states, and they don't behave alike:

  • Foreground — the app is open. Often you shouldn't show a system notification at all; an in-app indicator is less jarring than notifying someone about the screen they're looking at.
  • Background — the app is alive but not visible. Navigation works; the router exists.
  • Cold start — the app isn't running. The tap launches it, and the payload arrives before the navigation stack exists. Navigating immediately does nothing, or crashes.

Cold start is where routing bugs live. The payload has to be held until navigation is ready:

let pending = null;

function onNotificationTap(payload) {
  if (!navigationRef.isReady()) {
    pending = payload;          // arrived before the stack existed
    return;
  }
  routeTo(payload);
}

// Called once the navigator mounts.
function onNavigationReady() {
  if (pending) {
    routeTo(pending);
    pending = null;
  }
}

Platform differences compound it. Payload shape differs, the field carrying your routing data differs, and helper code that assumes one platform's shape produces notifications with a correct body and a wrong title — or a tap that goes nowhere — on the other.

Badge counts drift for the same reason counters do

The unread badge is a denormalised number maintained by two systems: the server that sends notifications and the client that displays them. Any path that marks something read without telling the other side leaves the badge wrong.

Users notice this more than almost any other bug, because it's visible on the home screen without opening the app. It's the same class of problem as a member count that drifts — a derived number updated in more than one place, with no reconciliation.

How to test it without losing a day

All of this is untestable in a simulator with a warm app. The states that break are cold start and a link arriving from outside the app, and reproducing them means force-quitting between attempts.

Per-pull-request builds that post an install link made this bearable — reviewers could try a notification tap on a real device from a genuinely cold start without building anything. That pipeline work paid for itself here more than anywhere else.

← All engineering notes