+1 (415) 943-4271

Push Notifications That Actually Arrive: APNs, FCM, and Live Activities in React Native

Notifications are the feature clients assume is a one-afternoon job and that eats a sprint. The SDK call is easy; everything around it — permissions, channels, tokens, entitlements, deep links, and the platform-specific ways a notification silently disappears — is not. Here is the shape of a notification stack we would be happy to hand over at the end of an engagement.

Pick a lane first: expo-notifications or bare FCM/APNs

expo-notifications covers the common case on both platforms with one API: permission prompts, token registration, foreground handlers, categories/actions, and scheduled local notifications. If you are on Expo (managed or CNG prebuild) and your payloads are ordinary, start here. Note the one hard limitation people trip over: remote push in Expo Go is no longer supported on modern SDKs — you need a development build to test push at all.

Bare @react-native-firebase/messaging (+ APNs directly) is the right call when you need FCM-specific behaviour: data-only background messages driving a sync, per-topic fan-out, or an existing backend already speaking FCM. You give up some of the Expo ergonomics and take on more native config.

Either way, your backend should store tokens keyed by user and device, with a last_seen timestamp, and prune on the NotRegistered / Unregistered responses the providers hand back. Stale token tables are the most common cause of "we sent 40,000 pushes and 12,000 landed".

Permissions and channels: the Android 13+ rules

Since Android 13, POST_NOTIFICATIONS is a runtime permission. Two consequences:

  1. Don't ask on first launch. Ask at a moment where the value is obvious — after the user follows something, enables an alert, or completes an order. A denied prompt on Android is expensive to recover from; the system limits re-prompts and you are left routing users into system settings.
  2. Create channels before the first notification. Android routes every notification through a channel, and the channel — not your payload — decides sound, importance, and whether it can bypass Do Not Disturb. Create them at startup with stable IDs:
import * as Notifications from 'expo-notifications';
import { Platform } from 'react-native';

export async function setUpChannels() {
  if (Platform.OS !== 'android') return;
  await Notifications.setNotificationChannelAsync('orders', {
    name: 'Order updates',
    importance: Notifications.AndroidImportance.HIGH,
    sound: 'default',
  });
  await Notifications.setNotificationChannelAsync('marketing', {
    name: 'News and offers',
    importance: Notifications.AndroidImportance.LOW,
  });
}

Separate channels are also a user-retention feature: someone who hates your marketing pushes can mute that channel instead of muting the app.

On iOS, decide deliberately between a standard prompt, a provisional authorization (notifications land quietly in Notification Centre with no prompt — excellent for earning trust before asking for the interrupting kind), and time-sensitive interruption levels, which require the corresponding entitlement and should be reserved for genuinely time-critical content.

Handling a notification in all three app states

The bugs live in the state matrix. Test each cell explicitly:

App stateWhat you must handle
ForegroundNothing is shown by default — you decide whether to display a banner or an in-app toast.
BackgroundThe OS shows it; your handler runs only if the user taps, or on a data/silent payload with the right background modes.
Cold start (killed)The tap that launched the app must be readable after your navigation tree mounts.

The cold-start case is where most "tapping the notification just opens the home screen" reports come from. Read the initial notification response and hold the intended route until navigation is ready:

const lastResponse = Notifications.useLastNotificationResponse();

useEffect(() => {
  const url = lastResponse?.notification.request.content.data?.url;
  if (url && navigationReady) router.push(String(url));
}, [lastResponse, navigationReady]);

Put a url (or a typed screen + params) in the payload data and route through the same deep-link handler your universal links use. One resolver, one place to test, no duplicate routing logic drifting apart.

Rich notifications: images, actions, and the service extension

An image or a custom sound in the payload is not enough on iOS. Attachments require a Notification Service Extension — a small native target that intercepts the payload, downloads the media, and attaches it before display. In a CNG project this is a config plugin plus a Swift file; in a bare project it is an extra Xcode target. Budget a day, including provisioning-profile surprises.

Action buttons (Reply, Mark done) are cheaper: register a notification category with actions on iOS, or attach actions to the Android notification, and handle the response through the same listener. Handle the action without forcing a cold start where you can — a background handler that hits your API and updates the badge feels dramatically better than an app launch.

Speaking of badges: treat the badge count as server-computed and included in the payload. Client-side incrementing drifts out of sync the moment a user reads something on the web.

Live Activities, Dynamic Island, and widgets

For delivery tracking, live scores, timers, or ride status, a stream of pushes is the wrong UX; a Live Activity is the right one. The reality for React Native teams:

  • The UI is SwiftUI in a widget extension. There is no way around writing some Swift; React Native's job is to start, update, and end the activity, and to hand over the state.
  • Updates arrive either from your app via a small Turbo/Nitro module bridge, or — better for anything that must update while the app is closed — via ActivityKit push tokens, which are separate per-activity tokens your backend must store alongside the device token.
  • Android's closest analogues are ongoing notifications with custom layouts and, on newer versions, progress-style live updates. Plan the two platforms as different designs sharing one data model, not as one cross-platform component.

Widgets follow the same pattern: SwiftUI/Glance UI on the native side, with shared data handed across via an App Group (iOS) or shared preferences/DataStore (Android). Community packages wrap the plumbing, but expect to own native code here. If your product roadmap has "widget" on it, scope it as a native mini-project, not a screen.

Debugging the ones that never arrive

When a notification does not show up, walk this list in order — it resolves the overwhelming majority of cases:

  1. Wrong build. Push does not work in Expo Go on current SDKs, and simulators only accept notifications via drag-and-drop payload files. Test on a device with a dev or preview build.
  2. Wrong APNs environment. A production certificate/key against a development token (or vice versa) fails silently. Check the environment your backend is targeting.
  3. Token never reached your server. Log registration failures loudly; do not swallow them in a try/catch.
  4. Permission is provisional or denied. Read the actual permission status rather than assuming your prompt succeeded.
  5. Android channel importance too low, so the notification is delivered but never heads-up displayed.
  6. Aggressive OEM battery management on some Android devices delays or drops background delivery entirely. Reproduce on the affected OEM, and consider a foreground service for anything genuinely critical.
  7. Payload shape. Data-only payloads behave completely differently from alert payloads on both platforms — be explicit about which you are sending, and why.

Add a server-side delivery log (token, payload, provider response, timestamp) before you launch. Without it, every user report becomes an unfalsifiable ghost story.

A sensible default stack

For a typical product we would ship: expo-notifications on a development/EAS build, permission requested at a value moment, three or four named Android channels, deep-link routing shared with universal links, server-computed badges, a token table with pruning, and a delivery log. Live Activities only where the product genuinely has live state, with the SwiftUI work scoped honestly.

If notifications on your app are unreliable — or you want Live Activities and widgets without turning your React Native team into reluctant SwiftUI developers — get in touch. It is the kind of well-bounded problem our consultants fix quickly.