All articles
Mobile DevelopmentAugust 28, 2026 6 min read

Push Notifications in React Native 2026: Expo Notifications vs FCM/APNs Direct

Expo Notifications is the easy path, but at some scale you outgrow it. Here's an honest breakdown of when to stay on Expo's push service and when to talk to FCM and APNs directly.

Every React Native team hits the same fork in the road: your app grows past a few thousand installs, marketing wants segmented campaigns, and someone asks why 8% of pushes never arrive. That's usually the moment the conversation about Expo Notifications vs going direct to FCM and APNs starts.

We've shipped both. Neither is wrong. But the tradeoffs in 2026 look different than they did two years ago, and the decision cost is real if you pick the wrong lane and have to migrate later.

What Expo Notifications actually is

A lot of teams conflate two things: the expo-notifications library (client-side APIs for permissions, handlers, categories) and the Expo Push Service (the server that relays your notifications to Apple and Google).

You can use the library without the service. That's the part worth remembering.

The library gives you a unified JS API for:

  • Requesting permissions on iOS and Android 13+
  • Handling foreground and background notification events
  • Setting notification channels, categories, and actions
  • Reading the launch notification when the app opens from a cold start

The Expo Push Service, on the other hand, is a hosted relay. You send it Expo push tokens (ExponentPushToken[...]), it translates them to APNs and FCM calls, and it handles the auth, retries, and receipts.

Why the service exists

APNs uses token-based JWT auth with a rotating .p8 key. FCM has moved fully to the HTTP v1 API with OAuth2 service account credentials. Both require you to keep secrets on a server, mint short-lived tokens, and handle a fairly noisy error surface — unregistered tokens, size limits, priority mismatches.

Expo's service abstracts all of that. For small and mid-sized apps, that's genuinely valuable engineering time you're not spending.

When Expo's push service is the right call

We'd stay on the Expo Push Service if most of these are true:

  • You send fewer than a few hundred thousand pushes a day
  • Your notification logic lives in a Node or Python backend that already talks to Expo's REST endpoint
  • You don't need per-message analytics beyond "did APNs/FCM accept it"
  • You're on Expo's managed workflow or Dev Client and don't want native config drift
  • You care about shipping fast more than shaving milliseconds off delivery

The developer ergonomics are hard to beat. A push looks like this:

import fetch from 'node-fetch';

async function sendPush(to: string, title: string, body: string) {
  const res = await fetch('https://exp.host/--/api/v2/push/send', {
    method: 'POST',
    headers: {
      Accept: 'application/json',
      'Accept-encoding': 'gzip, deflate',
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      to,
      title,
      body,
      sound: 'default',
      priority: 'high',
      channelId: 'default',
    }),
  });
  return res.json();
}

Compare that to setting up a service account, minting an OAuth token, and constructing an FCM v1 payload with the right android and apns sub-objects. It's not hard, but it's not one function either.

When you outgrow it

There are four situations where we've moved teams off the Expo Push Service and onto FCM/APNs direct.

1. You need real delivery analytics

Expo gives you receipts — you can poll to see if APNs or FCM accepted the message. That's not the same as "was it delivered to the device" or "did the user see it." If your growth team wants funnel data (sent → delivered → opened → converted), you'll want to be inside FCM's analytics or a purpose-built tool like OneSignal, Braze, or Customer.io.

FCM integrates with Firebase Analytics for delivery and open reporting on Android natively. On iOS you still need a Notification Service Extension to reliably track delivery, and that's the same whether you're on Expo's service or direct.

2. You need Notification Service Extensions and Content Extensions

Rich pushes with images, decrypted end-to-end payloads, or mutable content require a Notification Service Extension on iOS. Expo now supports these via config plugins, but at that point you're already writing Swift. Once you're writing Swift for the extension, sending directly to APNs from your backend often becomes the cleaner mental model.

3. You're doing high-volume campaigns

Expo's service has rate limits and, in our experience, is fine for transactional pushes but not great for blasting a million-user broadcast in five minutes. FCM and APNs will both happily accept that load. If marketing is running large campaigns on a cadence, going direct — or via a vendor CDP that goes direct — is the sane path.

4. You need Live Activities or push-to-start on iOS

Live Activities use a separate push token type (pushToStartToken and per-activity tokens) and only route through APNs with a specific push-type: liveactivity header. Expo's push service has been catching up here but you're often better off talking to APNs directly.

The FCM/APNs direct path in a React Native app

If you decide to go direct, here's the shape of it. You have two token types to manage per device:

  • On Android, an FCM registration token from @react-native-firebase/messaging
  • On iOS, an APNs device token (which you can also route through FCM if you want a single pipeline)

Most teams we work with use @react-native-firebase/messaging on both platforms and let FCM proxy APNs. That gives you one token type on your backend and one SDK to send from.

import messaging from '@react-native-firebase/messaging';

export async function registerForPush(userId: string) {
  const authStatus = await messaging().requestPermission();
  const enabled =
    authStatus === messaging.AuthorizationStatus.AUTHORIZED ||
    authStatus === messaging.AuthorizationStatus.PROVISIONAL;

  if (!enabled) return null;

  const token = await messaging().getToken();
  await fetch('/api/devices', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ userId, token, platform: Platform.OS }),
  });

  messaging().onTokenRefresh(async (newToken) => {
    await fetch('/api/devices', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ userId, token: newToken, platform: Platform.OS }),
    });
  });

  return token;
}

On the backend, sending via FCM v1 looks roughly 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 = process.env.FIREBASE_PROJECT_ID;

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

More code, more secrets to rotate, but you own the pipeline.

Things that bite you either way

A few pitfalls we see regardless of which path you pick:

  • iOS permission timing. Asking for push permission on first launch tanks opt-in rates. Ask when the user has hit a moment where the value is obvious — after they follow someone, save a search, complete a purchase.
  • Android 13+ POST_NOTIFICATIONS. You have to request it at runtime. If you're using Expo, the config plugin handles the manifest, but you still need to call the permission API.
  • Silent pushes on iOS get throttled aggressively. If you're using content-available: 1 to trigger background sync, don't count on it. iOS decides.
  • Token churn. Users reinstall, restore from backup, switch devices. Your backend needs to dedupe tokens and clean up unregistered ones, or your "delivered" numbers slowly drift.
  • App review. Reviewers will reject apps that request push permission before any meaningful UI, or that gate core functionality behind push consent.

Migration reality check

If you start on Expo's push service and later move to FCM direct, the migration isn't a rewrite. Your client code changes (Expo push tokens become FCM tokens), your backend changes (you talk to FCM instead of exp.host), but the notification handling logic in RN stays largely the same if you were using expo-notifications on top.

We've done this migration on a couple of client apps and it typically takes a sprint if the backend was already abstracted behind a sendPush(userId, payload) interface. If the Expo push token is scattered across every service that sends notifications, budget more.

Where we'd start

For a new app in 2026, we'd default to Expo Notifications (the library) plus the Expo Push Service until one of the four outgrow-it signals appears. Wrap your backend send function so the transport is a single swap. When you need real delivery analytics, rich pushes at scale, or Live Activities, migrate to FCM v1 with @react-native-firebase/messaging, and use APNs direct only for the iOS-specific features FCM doesn't proxy well.

If you'd rather skip the trial-and-error, our team does this work as part of our mobile engineering practice — happy to compare notes.

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

Want a team like ours?

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

Start a project