Push Notifications Visualized: Expo, APNs, FCM & App Lifecycle
Trace a notification from an Expo backend through APNs or FCM into foreground, background, and terminated app states—and then into a deep link.
Push Delivery Simulator
Open in Engineering LabWhy did a push notification never arrive? “The backend sent it” describes only the first edge in a route involving permission, tokens, Expo's service, APNs or FCM, the operating system, app state, and user action.
Registration creates an address, not a guarantee
Ask for notification permission in context, after explaining the value. A denied user must still be able to use the app, and settings need an honest opt-out path.
In an Expo project, registration commonly obtains an Expo push token that Expo maps to native provider credentials. Direct integrations instead send an APNs device token or FCM registration token to a backend. These token types are not interchangeable.
const permission = await Notifications.requestPermissionsAsync();
if (permission.status !== "granted") return;
const token = await Notifications.getExpoPushTokenAsync({ projectId });
await api.registerPushToken({ token: token.data, platform: Platform.OS });Exact Expo APIs and configuration change across SDK versions. Use the installed Expo Notifications documentation and test a development or release build on physical devices; push behavior is not fully represented by a simulator.
The delivery route
With Expo Push Service, the backend sends an Expo push token and payload to Expo. Expo routes toward APNs for iOS or FCM for Android. Direct provider integrations skip the Expo service but require the backend to manage provider-specific credentials and payloads.
Provider acceptance means the provider accepted responsibility for the message. It does not prove the device was online, the operating system presented it, or the user saw it. Expo push tickets and later receipts expose different stages; inspect both.
Tokens rotate and expire
A user can restore a device, reinstall the app, clear data, change system state, or otherwise receive a new token. Store multiple devices per user when the product requires it, update registration idempotently, and remove tokens that provider receipts mark permanently unregistered.
INSERT INTO push_tokens(user_id, token, platform, updated_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (token)
DO UPDATE SET user_id = excluded.user_id, updated_at = now();Never use a push token as authentication. It is a routing identifier. Protect the registration endpoint with real identity and prevent one account from registering arbitrary victim tokens.
Foreground, background, terminated
In the foreground, application code is running and typically decides whether to show an in-app banner, update visible data, or suppress noise. Do not assume the operating system will present the same UI as it does in the background.
In the background, a notification payload can be presented by the system according to permission and channel/category configuration. Background data execution is platform-controlled, time-limited, and not guaranteed merely because a message exists.
When terminated, a visible notification can launch the app after a tap. Initialization must retrieve the notification response at the correct lifecycle point, wait until navigation is ready, and validate the destination. Force-stopped Android apps and user-swiped or terminated iOS states have platform-specific constraints; do not promise silent background execution.
Notification payload versus data payload
A notification payload asks the OS to present user-visible content under platform rules. A data payload gives the application structured information to handle, subject to app state and background execution limits. Combined payloads can behave differently across iOS, Android, FCM priority, and library versions.
Keep the notification body minimal. Lock screens are visible to other people, so do not include access tokens, private medical detail, financial values, or sensitive message content. Send an opaque resource identifier and fetch authorized data after the app opens.
Tap and deep-link safely
Notifications.addNotificationResponseReceivedListener((response) => {
const route = parseAllowedRoute(response.notification.request.content.data);
if (route) router.push(route);
});Treat payload routes as untrusted input. Allowlist destinations, validate parameters, require authentication, and verify the current user can access the referenced resource. A notification for an account that has since signed out must not bypass navigation guards.
iOS and Android differences
iOS uses APNs authorization and categories/actions. Android uses FCM transport and notification channels whose importance the user can change; once created, important channel behavior cannot always be rewritten programmatically. Badge behavior, sound, grouping, background handling, token APIs, and permission prompts differ by OS version.
Android 13 and newer introduced a runtime notification permission for most apps, while older Android versions and vendor power management still create distinct behavior. iOS offers additional authorization modes and strict background limits. Test a platform matrix instead of forcing one universal lifecycle diagram.
Retries and receipts
Retry temporary provider errors with bounded exponential backoff and jitter. Do not retry an invalid/unregistered token forever. A backend timeout can leave delivery outcome unknown, so give each campaign or logical event a stable identifier and prevent accidental duplicate sends where product semantics require it.
Track requested, accepted, receipt error, opened, and in-app outcome as separate events. “Delivered” metrics often mean different things depending on provider and SDK.
Production checklist
- Explain value before requesting permission and honor opt-out everywhere.
- Register and refresh tokens idempotently; prune permanent failures.
- Keep sensitive content out of lock-screen payloads.
- Validate deep links and re-check authorization after every tap.
- Configure Android channels and iOS categories deliberately.
- Test foreground, background, terminated, offline, signed-out, and rotated-token states on real devices.
- Observe performance after launch; React Native Performance explains why startup work can delay navigation.
Key takeaways
- A token is a changeable delivery address, not a user identity.
- APNs/FCM acceptance is not proof of presentation or attention.
- Foreground, background, and terminated states invoke different responsibilities.
- Payloads and deep links cross a security boundary.
- Expo simplifies routing while platform behavior still matters.
Continue with shipping an Expo app using EAS, authentication with Expo and Supabase, or testing mobile failure states.
Where this appears in my projects
A focused mobile workout tracker for daily rep-based challenges, animated exercise progress, streaks, and simple bodyweight consistency.
A circular puzzle game that rethinks falling-block gameplay across a radial 2D board and a fully rotatable 3D globe, with handcrafted levels, progression, online services, and a cosmetic economy.
An offline-first habit tracker where daily rituals grow into a calm 3D grove. Built to make consistency visible, motivating, and less stressful.
Related Tutorials
See how JavaScript, React rendering, native/UI work, lists, images, and state updates compete for a frame—and which optimizations actually help.
The full path from a working Expo project to an approved App Store release: build profiles, credentials, versioning, TestFlight, and the review rejections that are easiest to avoid.
A production-minded approach to social sign-in in Expo: native Apple credentials, OAuth redirects, Supabase sessions, secure storage, and the identity edge cases that affect real users.
A production testing matrix for React Native apps: installation, authentication, offline behavior, purchases, lifecycle changes, and data migrations.