Skip to content
← Journal
11 min readAta Mohammadi

Push Notifications, Deep Linking and Device APIs in React Native: The Seams Nobody Documents

Push notifications, deep linking and device detection each have a tidy quick-start page. In production they are one feature, and the bugs live in the seams: the cold-start race between a notification tap and a router that has not mounted yet, the channel you cannot edit after creation, and the URL you should not trust just because it arrived in a push payload.

Push notifications, deep linking and device detection each ship with their own quick-start page: request a permission, call a getXAsync, done. Every one of those pages is accurate and every one of them is missing the part that actually breaks in production, because the interesting behaviour is not inside any single API call — it is in the seam between them. A push notification that does not deep link anywhere is an interruption with no purpose. A deep link fired from a notification tap while the app is cold-starting races your router. And most of the device API calls that matter here exist to answer one question — is it safe to do the expensive thing right now — not to report specs.

This is that seam, worked through with the real API surface: expo-notifications, expo-linking, expo-router and expo-device on Expo SDK 57 / React Native 0.86, which is what is actually running in the apps this was pulled from.

Push notifications: the permission, the token, and the environment split

There are two kinds of "push notification" in React Native, and the terminology collision is the first thing that trips people up. A local notification is scheduled on-device with scheduleNotificationAsync and needs no server, no token and no network — it is a timer with a UI. A remote push is sent from a backend through Apple's APNs or Google's FCM (or through Expo's push service, which sits in front of both) and requires a token, a server and a permission granted specifically for remote delivery. Habit reminders and pomodoro timers are local. "You have a new message" is remote. They share one API surface, which is convenient right up until you request the wrong permission for the one you meant.

Permission on both platforms is coarser than most apps' UI implies:

iOS:     .notDetermined → .provisional | .authorized | .denied
Android: POST_NOTIFICATIONS runtime permission (API 33+, one-shot like camera/location)

iOS's .provisional state is worth designing for deliberately: notifications go straight to Notification Center with no interruption and no permission prompt at all, which is the honest way to prove the value of a notification before asking for the right to interrupt with one. Requesting alert: true up front and eating the decline is the more common choice and the worse one — a denial is close to permanent, since the only way back is the OS settings app, which is why expo-linking's openSettings() exists as an escape hatch worth wiring into your own "notifications are off" screen rather than leaving as a dead end.

The foreground handler is where most apps get their first surprise, because the API changed shape recently and a lot of copy-pasted snippets still show the old one:

// expo-notifications@57 — shouldShowAlert is deprecated in favour of:
Notifications.setNotificationHandler({
  handleNotification: async () => ({
    shouldShowBanner: true,
    shouldShowList: true,
    shouldPlaySound: false,
    shouldSetBadge: false,
  }),
});

Two details that are easy to miss and both matter: handleNotification has three seconds to resolve, or the notification is discarded — if you're doing an async lookup (deduplicating against local state, say) inside the handler, that lookup needs its own timeout well under three seconds, not "however long the request takes." And the handler only runs in the foreground; a killed or backgrounded app never calls it, so any "don't show this notification if the user already read it elsewhere" logic that lives only in the handler silently stops applying the moment the app isn't open.

Fetching a token is one call, but which token depends on who is sending the push:

const expoPushToken = await Notifications.getExpoPushTokenAsync({
  projectId: 'your-eas-project-id',
});
// → { type: 'expo', data: 'ExponentPushToken[...]' }

getExpoPushTokenAsync is what you want if Expo's push service is relaying to APNs/FCM for you — send that string to your backend and let Expo handle the platform split. If your backend talks to APNs or FCM directly, call getDevicePushTokenAsync() instead, which returns the raw native token and nothing else. Both are documented as capable of rejecting on a flaky connection, and the docs are explicit that this is expected, not exceptional: "it can get rejected in cases where the request itself fails... you should try/catch this method and implement retry logic." An integration that fetches the token once on mount with no retry will have a real, measurable fraction of users who granted permission and were never actually registered.

iOS adds one more branch most guides skip: APNs has separate sandbox and production environments, and a token issued for one is worthless against the other. expo-application's getIosPushNotificationServiceEnvironmentAsync() reports which one the build is running under so you can tag the token server-side — send a sandbox token to production APNs and the send simply fails, with an error that looks identical to "token expired."

Android's side of this is a permission system of its own: every notification needs a channel, and channel importance is what actually gates whether Android shows a heads-up banner, plays a sound, or does nothing. Create channels before the first notification ever fires, not lazily on first use:

await Notifications.setNotificationChannelAsync('messages', {
  name: 'Messages',
  importance: Notifications.AndroidImportance.HIGH,
  sound: 'default',
});

The Android OS enforces something worth designing around from day one: after a channel is created, you can only change its name and description — never its importance or sound. Ship messages at DEFAULT importance and decide later that message notifications deserve HIGH, and you cannot flip it; you have to create messages-v2 and migrate. Version your channel IDs from the start (messages-v1) the same way you'd version a cache key.

Last, and this one costs people a whole afternoon the first time: Expo Go does not support remote push notifications. It hasn't for several SDK cycles, and on Android the SDK doesn't warn about it any more — it throws. Local notifications work fine in Expo Go; testing a remote push requires a development build (expo run:ios / expo run:android, or an EAS dev client). If a getExpoPushTokenAsync call is throwing in a way that makes no sense against the documented API, check whether you're still in Expo Go before you check anything else.

Deep linking: the router mounts after the tap, not before

A deep link is any URL that opens the app to a specific place instead of the home screen — a custom scheme (myapp://post/42), or a real HTTPS URL that the OS hands to your app instead of a browser (a universal link on iOS, an app link on Android). Both require the same two-sided proof: the app config declares the scheme or domain, and — for universal/app links specifically — the domain itself has to publish a file (apple-app-site-association under /.well-known/ on iOS, assetlinks.json on Android) that names your app's bundle ID and signing certificate. Neither the app nor the phone will treat a domain as yours until it says so from the server side; skipping that file is the single most common reason "deep links work in dev with the custom scheme but silently open Safari in production."

expo-linking gives you the plumbing that sits underneath both schemes:

Linking.createURL('/post/42', { queryParams: { ref: 'push' } });
// dev build:  myapp://post/42?ref=push
// Expo Go:    exp://192.168.x.x:8081/--/post/42?ref=push
// web (prod): https://myapp.com/post/42?ref=push

Linking.parse(url);
// → { scheme, hostname, path: 'post/42', queryParams: { ref: 'push' } }

Two things worth flagging here for anyone working from an older tutorial: useURL() is now deprecated in favour of useLinkingURL() — same contract, but the new hook reliably returns the initial URL on every reload instead of only sometimes — and expo-router makes most of this invisible anyway, because every file under app/ is already a route, and the router derives the linking config from the file tree instead of you hand-writing a path table.

The part that actually causes bugs is timing, not configuration. Consider the sequence when the app is killed and a user taps a notification:

  1. The OS launches the process from cold.
  2. Your JS bundle has not evaluated yet — no listeners are registered, expo-router has not mounted a single screen.
  3. The tap event that would normally fire addNotificationResponseReceivedListener already happened, before step 2 could possibly have a listener attached to catch it.

An addEventListener or addNotificationResponseReceivedListener call in a useEffect will reliably miss this exact case, because the event it's built to listen for already fired before the component existed. expo-notifications solves it with a hook built for precisely this race:

const lastResponse = Notifications.useLastNotificationResponse();

useLastNotificationResponse doesn't listen for a future event — it returns whatever tap already happened, including one that occurred before your JS booted, the same way getInitialURL() answers "was I launched by a link" instead of "notify me of the next one." Call clearLastNotificationResponse() once you've navigated on it, or the same cold-start tap will re-fire the navigation on every subsequent hot reload during development, and you'll spend twenty minutes convinced your router has a bug it doesn't have.

Device APIs: not a spec sheet, a set of gates

expo-device, expo-application and expo-network read as reference documentation — brand, model, battery, connection type — but the way they actually earn their place in an app is as preconditions, not display data. The question they answer is never "what device is this," it's "should I attempt the expensive/fragile thing right now."

Device.isDevice        // false on simulator/emulator — real push tokens don't exist there
Application.applicationId
Network.getNetworkStateAsync()  // { isConnected, isInternetReachable, type }

Device.isDevice gates the entire push-registration flow: attempting to fetch a push token on a simulator either throws or silently returns a token nothing can deliver to, and either way the retry logic from the previous section will happily retry a call that can never succeed. Check it first and skip registration entirely rather than let it fail its way through your retry budget. Network.getNetworkStateAsync() earns its place the same way expo-notifications' own docs recommend — you were told to retry the token fetch on failure; the network state is what tells you whether retrying now is worth the attempt or whether to wait for a connectivity change event instead of burning through your backoff schedule against a connection that isn't there.

The bug that ships quietly: trusting the payload

Here is the seam that connects all three, and the one worth being deliberate about because it is the one code review is most likely to wave through.

A push notification's data field is exactly the vehicle you'd use to carry a deep link — the expo-notifications docs' own example does Linking.openURL(response.notification.request.content.data.url). That is fine when the URL comes from your own backend. It stops being fine the moment anything else can shape that payload: a misconfigured push-relay service, a compromised server credential, or — on Android specifically — another app on the device sending a spoofed intent that your notification-tap handler wasn't written to distinguish from a real one. Passing whatever string arrives straight into router.push() or Linking.openURL() is an open-redirect into your own app's surface: a payload data field pointing at an internal-only route, a javascript:-flavoured scheme handler if one is ever registered, or simply a route your navigation stack can't handle and crashes on.

The fix costs four lines and is the only part of this article worth type-checking, because it's the one piece with no framework dependency at all:

const ALLOWED_ROUTES = ['/post/', '/thread/', '/profile/'] as const;

export function resolveNotificationRoute(rawUrl: unknown): string | null {
  if (typeof rawUrl !== 'string') return null;
  const path = rawUrl.replace(/^[a-z][a-z0-9+.-]*:\/\/[^/]*/i, '');
  return ALLOWED_ROUTES.some((prefix) => path.startsWith(prefix)) ? path : null;
}

Strip the scheme and host, check the remaining path against a known allowlist, and refuse to navigate — not throw, not fall back to a default route, just do nothing — on anything that doesn't match. router.push() should never see a string that didn't pass through this first.

Wiring it together

The full picture is smaller than the sum of its parts, because most of it is gating rather than logic:

// notifications.ts — imports expo-notifications, expo-device, expo-network,
// expo-router and react-native; none installed in this article's checker.

export async function registerForPushNotificationsAsync(): Promise<string | null> {
  if (!Device.isDevice) return null;

  const net = await Network.getNetworkStateAsync();
  if (!net.isInternetReachable) return null;

  const { status: existing } = await Notifications.getPermissionsAsync();
  const status =
    existing === 'granted'
      ? existing
      : (await Notifications.requestPermissionsAsync()).status;
  if (status !== 'granted') return null;

  if (Platform.OS === 'android') {
    await Notifications.setNotificationChannelAsync('messages-v1', {
      name: 'Messages',
      importance: Notifications.AndroidImportance.HIGH,
    });
  }

  try {
    const { data } = await Notifications.getExpoPushTokenAsync({ projectId: EAS_PROJECT_ID });
    return data;
  } catch {
    return null; // caller decides whether/when to retry
  }
}

// In the root layout:
const lastResponse = Notifications.useLastNotificationResponse();
useEffect(() => {
  const path = resolveNotificationRoute(lastResponse?.notification.request.content.data?.url);
  if (path) {
    router.push(path);
    Notifications.clearLastNotificationResponse();
  }
}, [lastResponse]);

Every branch in registerForPushNotificationsAsync is a place a naive version would have thrown, retried forever, or registered a token nobody could ever deliver to. None of it is complicated in isolation — it's complicated only in the sense that no single SDK's quick-start page mentions the other two.

The compressed version: treat push, deep linking and device checks as one feature, not three integrations. Gate registration on Device.isDevice and real connectivity before you touch the network. Version your Android channels, because you cannot edit importance after creation. Read the notification tap that happened before your JS booted with useLastNotificationResponse, not an event listener that was never there to catch it. And never call router.push() or Linking.openURL() on a string that arrived in a payload without checking it against a route allowlist first — a deep link is a redirect, and it deserves the same suspicion as one.


Sources: Expo push notifications, Expo Linking, Expo Router, Expo Device — cross-checked against the [email protected], [email protected], [email protected] and [email protected] packages and changelogs as installed in this codebase's Expo SDK 57 apps.

Next step

Tell us what is broken or what should exist.

Send the shape of the problem and any constraints you already know — budget, deadline, the stack you are stuck with. You will get a written reply from the engineer who would do the work, not a sales sequence.