All articles
Mobile DevelopmentOctober 20, 2026 6 min read

Push Notifications in React Native 2026: Expo Notifications, FCM HTTP v1, and the APNs Gotchas

A practical breakdown of push on React Native in 2026: Expo Notifications vs raw FCM/APNs, token lifecycles, HTTP v1 migration pain, and the silent failures that eat your delivery rates.

Push Notifications in React Native 2026: Expo Notifications, FCM HTTP v1, and the APNs Gotchas

Push looks like a solved problem until you ship it at scale. Then you discover that half your Android users never got the receipt notification, your iOS silent pushes are being throttled, and the token you stored six months ago is quietly dead. This is what push on React Native actually looks like in 2026.

The 2026 landscape: what actually changed

Two things reshaped the push story since 2023. First, Google fully retired the FCM legacy HTTP and XMPP APIs — if your backend is still posting to fcm.googleapis.com/fcm/send, it has been returning 404s for a while now. Everything goes through the HTTP v1 endpoint with OAuth2 service account auth. Second, Apple tightened the screws on background and silent notifications, and the system now aggressively defers or drops content-available pushes based on user engagement signals we don't fully control.

Meanwhile, Expo Notifications matured into a genuinely production-worthy option, not just a prototyping shim. The Expo Push Service sits in front of both FCM v1 and APNs, handles token translation, and gives you a single JSON contract. Whether you should use it is the first real decision.

Expo Push Service vs talking to FCM/APNs directly

Use the Expo Push Service when:

  • You want one request format for both platforms.
  • Your backend team doesn't want to manage APNs .p8 keys and Google service account JSON.
  • You're already on EAS and want the receipts dashboard.

Go direct to FCM v1 and APNs when:

  • You need per-message priority tuning, custom APNs headers like apns-collapse-id, or FCM analytics labels.
  • You're sending more than a few million pushes a day and want to avoid an intermediary hop.
  • You need HTTP/2 streaming to APNs for latency-sensitive messages (think ride-hailing, trading alerts).

In our experience, most product teams are better off with Expo Push until they aren't. The migration path to direct FCM/APNs is straightforward because the device tokens are already there — you just stop wrapping them.

Getting the tokens right

Half of all "push isn't working" tickets trace back to token handling. Here's the client-side pattern we use with expo-notifications:

import * as Notifications from 'expo-notifications';
import * as Device from 'expo-device';
import { Platform } from 'react-native';

export async function registerForPush(userId: string) {
  if (!Device.isDevice) return null;

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

  if (existing !== 'granted') {
    const res = await Notifications.requestPermissionsAsync({
      ios: { allowAlert: true, allowBadge: true, allowSound: true },
    });
    status = res.status;
  }

  if (status !== 'granted') return null;

  if (Platform.OS === 'android') {
    await Notifications.setNotificationChannelAsync('default', {
      name: 'default',
      importance: Notifications.AndroidImportance.DEFAULT,
    });
  }

  const projectId = process.env.EXPO_PUBLIC_EAS_PROJECT_ID;
  const { data: expoToken } = await Notifications.getExpoPushTokenAsync({ projectId });

  await fetch('/api/push/register', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({
      userId,
      token: expoToken,
      platform: Platform.OS,
      appVersion: Device.osBuildId,
    }),
  });

  return expoToken;
}

Three things this snippet gets right that most tutorials miss:

  1. It passes projectId explicitly. In bare and development builds, the auto-detection fails silently and you get a token tied to the wrong project.
  2. It stores app version alongside the token. When a user force-quits and reinstalls, you need to invalidate the old token server-side.
  3. It only runs on real devices. Simulators return garbage or throw, and your error reporter will light up for no reason.

The token lifecycle nobody documents

Tokens are not stable. They rotate when:

  • The user reinstalls the app.
  • The user restores from an iCloud or Google backup to a new device.
  • The OS invalidates the token for abuse signals (yes, this happens).
  • You rotate your FCM sender ID or APNs key.

Your backend needs a cleanup job. When FCM v1 returns UNREGISTERED or INVALID_ARGUMENT, or APNs returns a 410 with BadDeviceToken, delete the row. If you're using Expo Push, poll the receipts endpoint — DeviceNotRegistered means the same thing. Teams that skip this step end up with dead-token ratios north of 40% within a year, and their send volumes look healthy on paper while actual delivery craters.

FCM HTTP v1: the migration tax

If you're building a new backend in 2026, this is non-negotiable. The HTTP v1 contract is cleaner but more verbose. A minimal send looks like this:

import { GoogleAuth } from 'google-auth-library';

const auth = new GoogleAuth({
  scopes: ['https://www.googleapis.com/auth/firebase.messaging'],
});

async function sendFcm(token: string, title: string, body: string) {
  const client = await auth.getClient();
  const projectId = await auth.getProjectId();

  const res = await client.request({
    url: `https://fcm.googleapis.com/v1/projects/${projectId}/messages:send`,
    method: 'POST',
    data: {
      message: {
        token,
        notification: { title, body },
        android: {
          priority: 'HIGH',
          notification: { channel_id: 'default' },
        },
        apns: {
          headers: { 'apns-priority': '10' },
          payload: { aps: { sound: 'default', 'mutable-content': 1 } },
        },
      },
    },
  });

  return res.data;
}

The gotchas that burned us:

  • The notification block at the top level is cross-platform, but platform-specific overrides inside android and apns win. Don't put the same key in both and expect a merge.
  • Priority is split: Android uses HIGH/NORMAL, APNs uses the integer apns-priority header (10 or 5). Setting one does not set the other.
  • Topic sends and condition sends share the same endpoint but swap token for topic or condition. Don't send all three — the API rejects it rather than picking one.
  • Service account JSON belongs in a secret manager, not your repo and not your container env for anything above a prototype. The key grants send rights to every FCM device your project knows about.

The APNs quiet killers

APNs will cheerfully accept your payload and then never deliver it. The common causes we see in production:

Silent pushes get throttled. A notification with only content-available: 1 and no alert, badge, or sound is a background push. iOS budgets these aggressively based on how often the user opens your app. If your user hasn't launched the app in a week, your sync-triggering silent push is going nowhere. Design for this: assume background delivery is best-effort, and reconcile on next foreground.

Priority 10 on silent pushes is an instant reject. APNs returns BadPriority. Silent pushes must be priority 5.

Payload size limits bite richer notifications. Standard pushes cap at 4KB. If you're attaching images via notification service extensions, keep the trigger payload lean and fetch the asset in the extension.

Collapse IDs save you from spam. If a chat thread fires 20 messages while the device is offline, use apns-collapse-id set to the thread ID. The user gets one updated notification instead of 20, and your delivery stats stay honest.

Android 13+ permission flow

Android 13 made POST_NOTIFICATIONS a runtime permission, and by 2026 virtually every user is on 13 or newer. You can't just assume you have the right to notify. The Expo module handles the request, but you still own the UX decision about when to ask. Asking on first launch is a reliable way to get denied. Ask after the user does something that implies they want updates — placed an order, started a chat, enabled a reminder.

Measuring what actually arrives

Send success is not delivery, and delivery is not display. Build at least three counters:

  • Sent: your backend got a 200 from FCM/APNs/Expo.
  • Delivered: the device acknowledged receipt (FCM delivery receipts, Expo receipts endpoint, or your own ack via a silent push + API call).
  • Opened: the user tapped the notification, captured via addNotificationResponseReceivedListener.

If you only track sent, you're flying blind. In our experience, delivery-to-sent ratios in the 70–85% range are normal for a healthy app with active users. Below 60% means you have a dead-token problem or a priority misconfiguration.

Where we'd start

If you're greenfield in 2026: ship with Expo Notifications, store tokens with user and app version, wire up the receipts polling job on day one, and add the three-counter telemetry before you add anything fancy. Only move to direct FCM v1 and APNs when you have a concrete reason — custom headers, volume, or a backend team that wants the control.

If you're migrating off legacy FCM: do it this quarter, not next. The endpoint is already gone, and every day you delay is a day of silent send failures piling up in users' expectations. For a deeper walk-through on mobile release operations, our mobile development services page covers how we structure this kind of work end to end.

#React Native#Expo#Push Notifications#Mobile#FCM#APNs

Want a team like ours?

72Technologies builds production software for the kind of teams who actually read this blog.

Start a project