Push notifications look simple from the app side: your server calls a provider, the provider wakes the OS, the OS shows a banner. But the mobile-specific details — permission flows, cold starts, foreground vs background handlers, and the "just collapsed into an RSS feed of deals" trap — are where most implementations fall apart. This guide walks the architecture that actually survives production.
Local vs remote push#
Two different things, confused constantly:
- Local notification: scheduled and shown by the device itself. No network involved, works offline. Timers, reminders, calendar events.
- Remote push (APNs/FCM): your server sends a message to the platform, which delivers it to the device. Used for anything time-sensitive or user-triggered on the backend: chat messages, order updates, breaking news.
Decide which kind you need before touching any library. If all your notifications originate from within the app, local notifications cover you with zero backend and zero credentials setup.
The payload flow for remote push#
Your server → FCM/APNs → OS push daemon → device
The message your server sends is a JSON payload. Two parts matter:
- The OS-visible fields (
title,body,sound, ordataon Android) — rendered by the system without your JS running. - The
datapayload — arbitrary keys delivered to your app. On Android,data-only messages wake the app up; on iOS,content-available: 1is required and there are rate limits.
A typical payload:
{
"to": "device-token",
"notification": {
"title": "Your order shipped",
"body": "Parcel KRB001 has left the hub."
},
"data": {
"orderId": "KRB001",
"type": "order_status"
}
}
The data keys let your app navigate straight to a screen (deep-link to /order/KRB001) and let the server adjust behaviour without a new app release.
Permissions: the funnel you must measure#
iOS and Android both require opt-in, and the request itself is a conversion event. Pattern: ask for permission in context, not at first launch.
- First launch: use the app, don't ask.
- When the user performs the action that makes notifications valuable (they send a message, they place an order): ask.
Measurement matters. Permission acceptance rate is a dashboard metric. If it's below ~50%, the ask timing is wrong.
Android quirk: on Android 13+, permission is a runtime prompt (
POST_NOTIFICATIONS); before that it was automatic. Handle the three reply values —authorized,denied,provisional(iOS) — and show a settings deep-link when denied.
Handling taps, foreground, and background#
Notification taps are the primary return path; you need three handlers wired consistently:
- Tap while app killed (cold start) — apps often miss this. The OS launches the app with the payload; read it from your
getInitialNotificationbefore assuming normal startup. - Tap while app in background — fires the "notification response" listener.
- App in foreground — iOS does not show a banner by default; decide whether to present it. (Standard: yes, because the user glances at the lock screen.)
A compact state machine: capture the tap payload, navigate, and clear the captured value so a later cold start doesn't double-navigate.
The classic production bug: user taps a notification, app cold-starts, and the root screen renders instead of the target screen because the cold-start payload was never read.
The background-data trap#
"Can I send a silent notification and run code in the background?" For Android, data-only messages give you a short execution window (for work that must run despite background restrictions) but the OS may throttle or kill it depending on vendor/Doze. For iOS, content-available: 1 gives you ~30 seconds and Apple limits how often silent pushes are delivered.
If you need a guaranteed background job (syncing, refreshing a badge), push is not the tool. Use the platform's background task APIs with a sensible minimum interval instead.
Badges, channels, and the opt-out signal#
Badge count on iOS is set by the app, not the server — call setBadgeCount when the app is active, and reset it on open. Android notification channels group settings: messaging vs promotions. Grouping honestly is good product sense: if users can mute "promotions" but keep "messages," they keep the app installed.
The engage-again trap. Push is a retention lever, not a channel to exhaust. The moment notification volume makes the uninstall reach, the whole feature has cost the business more than it earned. Rate-limit and let users choose frequency.
The React Native stack, concretely#
Use a mature abstraction:
- iOS/Android remote:
@react-native-firebase/messaging(FCM) or push on its own - Local + rich UI: Notifee-style libraries handle channels, foreground presentation, and display payloads uniformly
Minimum wiring set:
- Request permission in context (see above)
- Subscribe device tokens and send them to your server (rotate tokens on reinstall)
- Register tap/foreground/background listeners before (or during) the mount path, guarding against double-registration after fast refresh
- Feed
data.type+ payload into your navigation function once
Common production gaps#
| Gap | Symptom | Fix |
|---|---|---|
| No cold-start handling | Taps working only when app is open | Read initial notification on startup |
| Asking permission at launch | Low opt-in rate | Ask in context, after the valuable action |
| Foreground banner missing on iOS | "push works on Android but not iOS" | Present-manual for foreground iOS |
| Badge counts from server | Stale/wrong badge numbers | Set/clear locally |
| Sending to stale tokens | Provider errors, deliverability drops | Prune tokens on failure (410/NotRegistered) |
Push is a small surface, but the failure modes are sneaky because platform behaviours differ. For the broader app-side story, React Native performance fundamentals covers why background work and rendering stay lean, and native, cross-platform, or PWA helps you decide whether push matters at all for your distribution model.