Mobile App Development

Push Notifications in React Native and Expo Done Right

Master React Native push notifications end-to-end — from token registration through APNs and FCM delivery — and debug the silent failures docs skip over.

By Laxaar Engineering Team Sep 4, 2026 11 min read
Push Notifications in React Native and Expo Done Right

Your code runs, no errors are thrown, and the notification never arrives. That's the default experience for most React Native push notification setups. Delivery runs through three separate systems: your server, Apple's APNs or Google's FCM, and the device itself. A silent failure at any link looks identical from the app's perspective. Nothing happens.

This post maps that entire chain. We'll cover token registration, credential wiring, Expo's notification service, foreground/background handling, and the specific spots where delivery quietly breaks. The Laxaar team has shipped notification systems across dozens of production apps, and we've catalogued the failure cases that official documentation consistently glosses over.

Getting the wiring right once means you're not re-diagnosing the same issues across every new project.

What you'll learn

How the APNs and FCM delivery chain actually works

React Native push notifications don't go directly from your server to the device. Every notification travels through at least one intermediary: Apple Push Notification service (APNs) for iOS, or Firebase Cloud Messaging (FCM) for Android.

The chain looks like this:

  1. The app registers with the OS on first launch and receives a device token.
  2. Your app sends that token to your backend.
  3. Your backend calls APNs or FCM with a payload addressed to that token.
  4. The platform gateway routes the message to the device.
  5. The OS wakes or foregrounds the app and delivers the notification payload.

Every link is a failure point. APNs and FCM will silently drop a message if the token is expired, the payload is malformed, the certificate is wrong, or the app isn't in the correct entitlements state. None of these produce an obvious error on the app side. You just get silence.

StepWhat can failHow it shows up
Token registrationPermissions denied, simulator limitationsgetExpoPushTokenAsync returns null
Token storageRace condition, network errorServer has no token for the user
Server-to-APNs/FCMBad credentials, invalid payload4xx from APNs/FCM, often swallowed
Gateway-to-deviceApp in kill state, background restrictionsNotification never appears
App handlingNo listener registeredNotification delivered but not acted on

The opinionated take: treat the token as a volatile credential, not a stable ID. Tokens expire when a user reinstalls the app, restores from backup, or in some cases just updates iOS. If you're not refreshing tokens and pruning expired ones, you'll eventually have a notification system that looks healthy in dashboards but reaches a shrinking percentage of users.

Token registration and common registration failures

Token registration is the first thing engineers get wrong, and the mistake is usually one of timing or scope.

On both iOS and Android, the app must request notification permissions before the OS will hand over a push token. On iOS, this is a hard gate. On Android 13 and above (API level 33), it's also required. Skip the permission request and you get no token, no error.

Here's the correct sequence with Expo:

import * as Notifications from 'expo-notifications';
import * as Device from 'expo-device';
import Constants from 'expo-constants';

async function registerForPushNotifications(): Promise<string | null> {
  if (!Device.isDevice) {
    console.warn('Push notifications require a physical device.');
    return null;
  }

  const { status: existingStatus } = await Notifications.getPermissionsAsync();
  let finalStatus = existingStatus;

  if (existingStatus !== 'granted') {
    const { status } = await Notifications.requestPermissionsAsync();
    finalStatus = status;
  }

  if (finalStatus !== 'granted') {
    return null;
  }

  const projectId = Constants.expoConfig?.extra?.eas?.projectId;
  const token = await Notifications.getExpoPushTokenAsync({ projectId });

  return token.data;
}

Common failures at this step:

  • Running on a simulator: iOS simulators can't receive real APNs tokens. You'll get a token that looks valid but will fail at delivery. Use a physical device or the Expo Go app.
  • Missing projectId: If projectId is undefined, getExpoPushTokenAsync will fail with a cryptic error. Always pass it explicitly from app.json / eas.json.
  • Calling registration too early: Registering before the component is mounted, or before the permissions dialog has resolved, leads to race conditions. Wrap registration in a useEffect with an empty dependency array.
  • Not handling Android notification channels: On Android 8+, you must create a notification channel before sending local or remote notifications. Without one, notifications silently fail on many Android 8–12 devices.
// Required for Android 8+
if (Platform.OS === 'android') {
  await Notifications.setNotificationChannelAsync('default', {
    name: 'Default',
    importance: Notifications.AndroidImportance.MAX,
    vibrationPattern: [0, 250, 250, 250],
    lightColor: '#FF231F7C',
  });
}

Expo push notification service vs direct APNs/FCM

Expo provides a proxy notification service that accepts a single request and fans out to both APNs and FCM. This is the fastest path for most projects.

Expo Push Service handles credential rotation, platform payload translation, and batching. You send one HTTP request to https://exp.host/--/api/v2/push/send with an Expo push token and a JSON payload, and Expo handles the rest.

Direct APNs/FCM means your server calls the platform APIs directly, which requires managing certificates and service account keys yourself. The upside is one fewer third-party in the delivery chain and full control over APNs priority, TTL, and collapse keys.

The real trade-off is operational. Expo's service hides credential complexity but introduces a dependency. If exp.host has an outage, your notifications are blocked regardless of your infrastructure state. Direct APNs/FCM removes that dependency but means you own certificate renewals. APNs p8 keys don't expire, but p12 certificates do, and they cause silent failures the day they expire with no warning.

For Laxaar's client projects under active development, we default to Expo's service and migrate to direct APNs/FCM if the product has strict delivery SLAs or needs fine-grained APNs control like voIP push. The migration path is clean because the token format is compatible.

Credential setup for APNs and FCM

This is where the most time gets lost in production debugging.

APNs credentials require an Apple Developer account, and you have two options:

  1. APNs Auth Key (.p8): Doesn't expire. One key can work across multiple apps. Requires your Team ID, Key ID, and bundle identifier at send time. Strongly preferred.
  2. APNs Certificate (.p12): Expires annually. Per-environment (development vs production). A common source of silent failures: the certificate expires over a weekend and no one notices until users report missing notifications.

If you're using Expo's managed workflow with EAS Build, run eas credentials to let EAS provision and store APNs credentials automatically. This is the right call for most teams.

FCM credentials: Firebase now requires a service account JSON for server-to-server calls (the legacy FCM server key is deprecated). Download the service account JSON from your Firebase project, store it as a secret in your backend environment, and use it to obtain short-lived access tokens.

# EAS credentials flow — handles APNs p8 and provisioning profiles
eas credentials --platform ios

One non-obvious credential issue: bundle ID mismatch. If your app.json bundleIdentifier doesn't exactly match what's registered in your Apple Developer portal and your APNs key, notifications fail silently. Check case sensitivity. com.Company.App and com.company.app are different identifiers.

Handling notifications in foreground, background, and killed states

Notification behavior is different in all three app states, and the handling code must account for each.

Foreground: By default, Expo suppresses the visual notification banner when the app is in the foreground. You need to explicitly configure the behavior:

Notifications.setNotificationHandler({
  handleNotification: async () => ({
    shouldShowAlert: true,
    shouldPlaySound: true,
    shouldSetBadge: false,
  }),
});

Background: The OS delivers the notification and may wake the app briefly via a background task. You can't run arbitrary code here: only background fetch tasks registered with TaskManager. If your app needs to update local state when a notification arrives silently, register a background task and check whether the OS actually calls it (Android is far more permissive than iOS here).

Killed state: The app was fully terminated. When the user taps the notification, the app cold-starts with the notification data in the launch options. Capture this in your root component:

useEffect(() => {
  // Handle notification that launched the app from killed state
  Notifications.getLastNotificationResponseAsync().then(response => {
    if (response) {
      handleNotificationNavigation(response.notification.request.content.data);
    }
  });

  // Handle notifications tapped while app is backgrounded or foreground
  const subscription = Notifications.addNotificationResponseReceivedListener(response => {
    handleNotificationNavigation(response.notification.request.content.data);
  });

  return () => subscription.remove();
}, []);

The common mistake: only registering the response listener, which misses the killed-state case entirely. You need both getLastNotificationResponseAsync and the listener.

Debugging silent delivery failures

Silent failures are the norm, not the exception. When a notification doesn't arrive, work through these checks in order before assuming there's a code bug.

Check the Expo push receipts API. When you send via Expo's service, call the receipts endpoint 15–30 minutes later with the ticket IDs you received. The receipt will tell you if APNs or FCM rejected the delivery.

// Fetch receipts to check delivery status
const response = await fetch('https://exp.host/--/api/v2/push/getReceipts', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ ids: ticketIds }),
});
const { data } = await response.json();
// data[ticketId].status === 'ok' | 'error'
// data[ticketId].details.error for 'DeviceNotRegistered', 'MessageTooBig', etc.
Common receipt error codes and their causes:
  • DeviceNotRegistered: The token is no longer valid. Remove it from your database.
  • MessageTooBig: Your payload exceeds 4 KB for APNs or the FCM limit. Trim the data.
  • InvalidCredentials: Your APNs p8 or FCM service account is wrong or expired.
  • MessageRateExceeded: You're sending too fast. Back off and retry.

Platform-specific checks: On iOS, verify the app has the aps-environment entitlement set to production in a production build. Development builds use the APNs sandbox. A token from a development build will be rejected by production APNs and vice versa. This mismatch is the number-one cause of "works on my phone, broken in production" push notification bugs.

On Android, confirm the google-services.json file is included in the build and matches the applicationId in your Gradle config. A mismatch here causes FCM registration to fail at the SDK level with no useful error surfaced to JavaScript.

The Laxaar team uses a simple internal health-check: after every production deploy, send a test notification to a known-good token and verify receipt status. It takes 30 seconds and catches credential issues before users report them.

Frequently Asked Questions

Why does my push token work in development but fail in production?

iOS uses separate APNs environments for development and production builds. A token obtained in a debug or Expo Go build is tied to the APNs sandbox. When you ship a production build, iOS generates a new token tied to the production APNs endpoint. If your server still holds the sandbox token, delivery silently fails. Always re-register tokens after switching to a production build and store them against the environment.

Do push notifications work in the iOS simulator?

Not with real APNs tokens. The iOS simulator can simulate local notifications, but it can't register for remote push notifications and won't receive them from APNs. You'll need a physical device, TestFlight, or the Expo Go app on a real device to test the full end-to-end flow.

How do we handle users who have multiple devices?

Store every token a user registers, not just the latest one. A user with an iPhone and an iPad will generate two distinct tokens. When you send a notification to that user, fan out to all their stored tokens. Use the receipt API to prune DeviceNotRegistered tokens so your list stays clean over time.

What's the maximum payload size for push notifications?

APNs allows 4 KB per notification payload. FCM allows 4 KB for data messages and 4 KB for notification messages. If you need to deliver more data, send a lightweight notification with a reference ID and have the app fetch the full data from your API when it receives the notification. This pattern also works better for stale notifications: by the time a user sees a delayed notification, your API can return fresh data.

Why do Android notifications sometimes not show on certain devices?

Android OEMs (especially Xiaomi, Huawei, OnePlus, and Samsung on aggressive battery profiles) implement custom battery optimization that can kill background processes and delay or block FCM delivery. This is the "Chinese Android problem" in mobile development. The fix is partially in your code (request the user to whitelist your app from battery optimization) and partially outside your control. On Huawei devices without Google Play Services, FCM doesn't work at all and you need HMS Push Kit.


Push notifications are one of those features where the happy path is quick and the edge cases are expensive. Getting the token lifecycle right, understanding the three app states, and building a receipt-polling habit into your deployment process will save you hours of production debugging.

If you're building a mobile app and want a team that has already solved the non-obvious parts, the Laxaar mobile development team is ready to help. Check out our mobile app work or get in touch to talk through your project. We also cover related topics like Expo vs bare React Native and cross-platform architecture decisions in our blog.

Working on something like this?

Get a fixed scope, timeline, and price within one business day — no obligation.

React NativeExpoPush Notifications
Grow your business with us

Take your business to the next level.

Tell us what you're building. We'll come back inside one business day with a fixed scope, timeline, and team — or an honest “this isn't a fit”.

ENGINEERING PHILOSOPHY

Code is useless if it's not comprehensible to those who maintain it. We write code the next person can actually understand.