Field guide

Expo push notifications with FCM + APNs, the 2026 setup that actually delivers

The 30-minute setup, the end-to-end delivery chain, the questions the docs bury (setNotificationChannelAsync sound, a FastAPI backend, FCM v1, is it free), and a catalog of the twelve failure modes we see most when a previously-fine push pipeline starts silently dropping 30-40% of notifications. Drawn from the same 47-app maintenance corpus behind our OTA Updates study.

Push notification delivery chain across Expo, FCM and APNs
By Ritesh + Prince24 min read

The delivery chain

Before we look at failure modes, the path a push takes from your backend to a user's lock screen. Most regressions are easier to diagnose when you know exactly which hop is failing.

One push, end-to-endtext
Your backend
   │   (1) POST to Expo Push API with ExponentPushToken
   ▼
Expo Push Service
   │   (2) lookup project credentials, forward
   ▼
   ├──── FCM (Android) ────────► Google Play Services ──► Device
   └──── APNs (iOS) ───────────► Apple Push Network    ──► Device
                                                            │
                                          OS displays banner│
                                                            ▼
                                                      User taps
                                                            │
                                           ┌────────────────┘
                                           ▼
                              App launches → Notifications handler
                                            → analytics event

Five hops, three vendors, two operating systems, and at least one OS-level behaviour (Doze, App Standby, Low Power Mode) that can drop the push silently. That is the surface area we are working with.

The 30-minute setup

The end-to-end setup, assuming you have a working Expo app already. We are using Expo Application Services (EAS) for builds and the Expo Push API for sending. The same shape works if you go direct to FCM and APNs, Expo just removes one credential bundle.

1. Project + EAS configuration

app.json, notifications blockjson
{
  "expo": {
    "name": "My App",
    "slug": "my-app",
    "ios": {
      "bundleIdentifier": "com.mycompany.myapp",
      "infoPlist": {
        "UIBackgroundModes": ["remote-notification"]
      }
    },
    "android": {
      "package": "com.mycompany.myapp",
      "googleServicesFile": "./google-services.json",
      "useNextNotificationsApi": true
    },
    "notification": {
      "icon": "./assets/notification-icon.png",
      "color": "#0d9488",
      "iosDisplayInForeground": true
    },
    "plugins": [
      [
        "expo-notifications",
        { "icon": "./assets/notification-icon.png", "color": "#0d9488" }
      ]
    ]
  }
}

2. Token registration (client)

src/notifications/register.tstypescript
import * as Device from "expo-device";
import * as Notifications from "expo-notifications";
import { Platform } from "react-native";
import Constants from "expo-constants";

export async function registerForPushAsync(userId: string): Promise<string | null> {
  if (!Device.isDevice) return null;

  const { status: existing } = await Notifications.getPermissionsAsync();
  let status = existing;
  if (existing !== "granted") {
    const req = await Notifications.requestPermissionsAsync();
    status = req.status;
  }
  if (status !== "granted") return null;

  if (Platform.OS === "android") {
    await Notifications.setNotificationChannelAsync("default", {
      name: "Default",
      importance: Notifications.AndroidImportance.HIGH,
      sound: "default",
      vibrationPattern: [0, 250, 250, 250],
    });
  }

  const projectId =
    Constants.expoConfig?.extra?.eas?.projectId ??
    Constants.easConfig?.projectId;

  const token = (await Notifications.getExpoPushTokenAsync({ projectId })).data;

  // Persist on the backend, scoped to this user + this install
  await fetch(`${API}/push/tokens`, {
    method: "POST",
    headers: { "Content-Type": "application/json", "X-Auth": await auth() },
    body: JSON.stringify({
      user_id: userId,
      token,
      platform: Platform.OS,
      app_version: Constants.expoConfig?.version,
    }),
  });

  return token;
}

Runs on first launch and on every fresh login. The duplicate-token check on the backend is what prevents the same device from accruing N stale tokens over its lifetime.

3. Sending (backend)

server/push/send.ts, server-side sendtypescript
import { Expo, ExpoPushMessage, ExpoPushTicket } from "expo-server-sdk";

const expo = new Expo({ accessToken: process.env.EXPO_ACCESS_TOKEN });

export async function sendPushes(messages: ExpoPushMessage[]) {
  const tickets: ExpoPushTicket[] = [];
  const chunks = expo.chunkPushNotifications(messages);

  for (const chunk of chunks) {
    try {
      const ticketChunk = await expo.sendPushNotificationsAsync(chunk);
      tickets.push(...ticketChunk);
    } catch (err) {
      console.error("expo push chunk failed", err);
    }
  }

  // Persist the tickets, we look them up later via getPushNotificationReceiptsAsync
  await db.insertPushTickets(tickets);
  return tickets;
}

// Run this on a cron, ~15 minutes after sends. Receipts expire after 24h.
export async function reconcilePushReceipts() {
  const ticketIds = await db.unreconciledTicketIds();
  const chunks = expo.chunkPushNotificationReceiptIds(ticketIds);
  for (const chunk of chunks) {
    const receipts = await expo.getPushNotificationReceiptsAsync(chunk);
    for (const [id, receipt] of Object.entries(receipts)) {
      if (receipt.status === "error") {
        const code = receipt.details?.error;
        if (code === "DeviceNotRegistered") {
          await db.invalidateToken(id);
        }
        // log; surface MessageTooBig / MismatchSenderId / etc.
      }
      await db.markReceiptReconciled(id);
    }
  }
}

Expo Push API takes batches of up to 100 messages per request. The DeviceNotRegistered receipt result is the signal to drop the token from your DB, keep ignoring it and your delivery rate quietly decays.

How we measure “delivered”

The single metric to instrument before anything else: device-side received rate. Expo / FCM / APNs will all return a successful ticket / receipt long before a push actually appears on the device. The only reliable signal that delivery happened is the app itself reporting the receipt.

src/notifications/instrument.ts, device-side receipttypescript
import * as Notifications from "expo-notifications";

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

Notifications.addNotificationReceivedListener((event) => {
  const pushId = event.request.content.data?.pushId;
  if (pushId) reportReceipt(pushId, "received");
});

Notifications.addNotificationResponseReceivedListener((event) => {
  const pushId = event.notification.request.content.data?.pushId;
  if (pushId) reportReceipt(pushId, "opened");
});

async function reportReceipt(pushId: string, kind: "received" | "opened") {
  try {
    await fetch(`${API}/push/receipts`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ pushId, kind, at: Date.now() }),
    });
  } catch {
    // best-effort; do not block UI
  }
}

Runs on every push, foreground or background. The push id is set server-side on the data payload so we can correlate to the original ticket.

On our maintenance corpus, the gap between ticket-success and device-received averages 12-18% on Android and 4-8% on iOS. The gap is real, mostly Doze + App Standby on Android and Low Power Mode + Focus filters on iOS, and the only way you find out about it is by measuring both ends. Every RN app we ship through our React Native app development engagement comes wired with the receipt instrumentation above on day one.

Twelve failure modes, in roughly the order we see them

Each item: symptom, root cause, the fix, and the prevention we ship by default. The numbers are the share of incidents we have responded to in the last 18 months that mapped to each cause.

1. APNs key revoked or rotated, Expo not updated (23%)

Symptom. Sudden 100% iOS delivery drop; Expo dashboard shows InvalidProviderToken in receipts. Cause. APNs auth key (.p8) rotated in the Apple Developer Portal but not re-uploaded to Expo via eas credentials. Fix. Re-upload the new key. Prevention.Calendar reminder 30 days before APNs key expiry, plus a synthetic push test run by a cron from the backend to a dedicated “canary” device.

2. FCM project mismatch (14%)

Symptom. Android tokens look valid; receipts say MismatchSenderId. Cause. The google-services.json at build time was from a different Firebase project than the one whose service account is uploaded to Expo. Fix. Re-download google-services.json from the correct project, rebuild, redeploy. Prevention. Track the Firebase project id alongside the EAS project id in a single Notion doc per app.

3. DeviceNotRegistered tokens not pruned (12%)

Symptom. Delivery rate drops slowly over months; ticket success rate looks normal. Cause. Dead tokens (uninstalls, re-installs that issued new tokens) are still in your database. Every send wastes a ticket. Worse, on iOS, sending to too many dead tokens triggers throttling. Fix. Implement the reconcilePushReceipts cron from the setup section and act on DeviceNotRegistered. Prevention. Make the reconcile job a CI required check, if it has not run in 48 hours, alert. This monitoring usually lives inside our maintenance retainer for the same reason, it's the kind of cron that drifts unless someone owns it.

4. Android notification channel mis-configured (9%)

Symptom. Android pushes arrive but are silent; users complain they never see anything. Cause. The default channel was created at low importance, or a custom channel was created without setting importance: HIGH. Fix. Update channel creation to use AndroidImportance.HIGH. Note: existing channels cannot have importance changed once created, you have to create a new channel with a new id. Prevention. Use a versioned channel id (e.g. default-v2) so future importance changes are possible.

5. iOS background-modes missing from Info.plist (7%)

Symptom. Silent push (data-only) is ignored when the app is backgrounded. Cause. remote-notification not in UIBackgroundModes. Fix. Add it in app.json under ios.infoPlist, rebuild. Prevention. Lint the app.json in CI for the keys you depend on.

6. App in “Doze” / battery optimisation (6%)

Symptom. Push arrives 1-30 minutes late on certain Android device-manufacturer combinations (Xiaomi, Huawei, Vivo are the usual suspects). Standard priority is the cause; highpriority bypasses battery optimisation but counts against FCM's daily quota. Fix. Send time-sensitive pushes with priority: "high"; keep marketing pushes at normal. Prevention. Tag every push with a category on the backend; the sender picks priority from category, not ad-hoc.

7. iOS Focus / Do Not Disturb filter (6%)

Symptom.Push appears in Notification Center but never plays a sound / banner. User says “nothing happened”. Cause. User is in a Focus mode that filters your app, or has Notification Summary on. Fix. Nothing you can do server-side. The app can prompt the user to allow Time Sensitive notifications, which bypass most Focus filters. Prevention. Document Time Sensitive eligibility (specific categories only) and request the entitlement at app review.

8. Notification icon shows as a white square on Android (5%)

Symptom. Pushes arrive on Android but the icon is a solid white blob. Cause. Notification icon must be monochrome with transparent background; using a coloured PNG produces the white-square fallback Android draws when the asset fails its constraints. Fix. Generate a proper monochrome alpha icon at 96×96. Prevention. Include the icon in the EAS Build asset audit script.

9. Same user signed in on multiple devices, only latest gets push (4%)

Symptom. A user with phone + tablet sees the push on one device only. Cause. Backend is keyed by user id and keeps only one push token per user. Fix. Key the push-tokens table by (user_id, device_install_id). Prevention. Default the schema this way on every new project, the cost is zero, the upgrade path is painful.

10. Expo Push API rate limits hit during a fan-out (4%)

Symptom. Mass send (campaign, announcement) succeeds for the first ~60% then ticket creation slows or errors. Cause. Expo limits the Push API to ~600 messages / second per project. Fix. Schedule fan-out over 5-10 minutes, not all at once. Prevention. Run all marketing pushes through a queue (BullMQ, SQS, etc.) with concurrency that respects the API budget.

11. Notification payload exceeds 4 KB (3%)

Symptom. Specific pushes return MessageTooBig in the receipt; users do not see them. Cause. Embedded image URL, full message body, or rich payload exceeded the 4 KB APNs limit (FCM is effectively the same on Android). Fix. Send only the metadata the OS displays; have the app fetch the rest on tap. Prevention. Lint the push payload size in the send function.

12. EAS build using a stale notification entitlement (3%)

Symptom. Pushes work fine for everyone except users on the new TestFlight build. Cause. The provisioning profile baked into the build did not include the push entitlement. Fix. Rebuild after running eas credentials to regenerate the profile. Prevention. Push entitlement check as part of the pre-submission checklist, the same checklist we ship for avoiding App Store rejection and the one our app store launch engagement runs against every production build before it goes to review.

Four questions the docs bury

These are the exact questions teams land on this page for, and the ones the official docs answer in scattered pieces. Here they are in one place.

setNotificationChannelAsync: sound, importance, and the null / silent gotcha

On Android the channel owns the sound and the importance, not the individual push. If you pass sound: null to setNotificationChannelAsync (or omit it) you get a silent channel; pass sound: "default" for the system sound, or a filename in android/app/src/main/res/rawfor a custom one. The trap that fills our inbox: a channel's sound and importance are frozen the instant it is created. You cannot make a silent or low-importance channel audible later by changing the code, the OS ignores the update. The fix is always to create a new channel id (e.g. default-v2) with the right settings and migrate users to it, which is why we ship versioned channel ids from day one.

Sending from a Python / FastAPI backend

Nothing about Expo push requires a Node backend. The Expo Push API is a plain HTTP endpoint, so a FastAPI service can send in a few lines, batches of up to 100 messages, then a receipts poll ~15 minutes later to prune DeviceNotRegistered tokens exactly as the TypeScript worker above does.

app/push.py, FastAPI send + receiptspython
import httpx

EXPO_URL = "https://exp.host/--/api/v2/push/send"

async def send_pushes(messages: list[dict]) -> list[dict]:
    # messages: [{"to": "ExponentPushToken[...]", "title": ..., "body": ..., "data": {...}}]
    async with httpx.AsyncClient(timeout=30) as client:
        tickets: list[dict] = []
        for i in range(0, len(messages), 100):           # Expo caps at 100/req
            resp = await client.post(
                EXPO_URL,
                headers={"Authorization": f"Bearer {EXPO_ACCESS_TOKEN}"},
                json=messages[i : i + 100],
            )
            resp.raise_for_status()
            tickets.extend(resp.json()["data"])
        return tickets  # persist ticket ids; poll /getReceipts ~15 min later

The exponent-server-sdk Python package wraps the same calls if you prefer chunking and retries handled for you.

FCM v1 (useFcmV1) and the credential that breaks Android

Google retired the legacy FCM API, so Android delivery now runs over FCM v1. Recent Expo SDKs default to it, so with expo-server-sdk you generally set nothing. What actually matters is the credential: you must upload an FCM v1 service-account key (a JSON key, not the old server key) with eas credentials. A missing or legacy credential is the single most common cause of a sudden Android-only delivery drop showing MismatchSenderId in the receipts, well ahead of any code change.

Is Expo push free? What actually costs money in 2026

Sending through the Expo Push API is free and unmetered, and FCM and APNs are free to send through too, so the push delivery costs nothing. What you pay Expo for is EAS: builds, and EAS Update (OTA), which has a free tier plus usage-based paid plans. So the number to budget is EAS build minutes and OTA bandwidth, not per-notification cost, we work through that side in the OTA updates cost study.

What to instrument on day one

A push system without instrumentation degrades silently. The four signals worth wiring before you ship anything:

  • Ticket success rate per send: drops below 95% means a credential or quota problem upstream.
  • Receipt errors by type: DeviceNotRegistered is healthy at 1-3% per day; InvalidProviderToken should be 0%.
  • Device-received rate: the gap between tickets and device receipts. Anything over 20% is a real problem.
  • Open rate by category: protects you against deliverability problems that look like product problems.

Two mobile-cluster companions, and one that covers the upstream cause of misconfigured push setups on AI-built prototypes:

The end-to-end mobile build that ships this push pipeline by default, the retainer that owns it post-launch, and the submission discipline that keeps it active through every release:

Frequently asked questions

Why does our push delivery rate decay over time?
Most often, dead tokens are not being pruned. Uninstalls and re-installs create new tokens; the old ones stay in the database wasting tickets and eventually triggering throttling. Reconcile receipts on a cron, act on DeviceNotRegistered by invalidating the token, and the slow decay stops.
Why do some pushes show up silent on Android?
Notification channel misconfiguration. The channel was created at low importance, or a custom channel was created without setting importance to HIGH. Once a channel is created the importance cannot be changed, you have to ship a new channel id (e.g. `default-v2`) and migrate users to it.
What signal proves a push was actually delivered to a device?
A device-side received event. Expo, FCM and APNs all return success on the ticket / receipt long before a push appears on the lockscreen. The only reliable signal is the app itself reporting `addNotificationReceivedListener` callback fires. The gap between ticket-success and device-received averages 12-18% on Android in our sample.
What does `sound: null` (or a silent channel) mean in setNotificationChannelAsync?
On Android the notification channel decides sound and importance, not the individual push. Passing `sound: null` (or omitting it) to `setNotificationChannelAsync` creates a channel that plays no sound; `sound: 'default'` plays the system default. Because a channel's sound and importance are frozen the moment it is created, you cannot make a silent channel audible later, you have to create a new channel id (e.g. `default-v2`) and migrate users to it.
How do I send Expo push notifications from a Python / FastAPI backend?
POST to the Expo Push API (https://exp.host/--/api/v2/push/send) in batches of up to 100 messages, each carrying the recipient's ExponentPushToken, then poll the receipts endpoint about 15 minutes later to catch DeviceNotRegistered. Use the `exponent-server-sdk` Python package or a plain httpx call from FastAPI, the ticket-then-receipt reconcile loop is identical to the TypeScript one in this guide.
Is the Expo push notification service free in 2026?
Sending through the Expo Push API is free and unmetered, and FCM and APNs are free to send through as well. What you pay Expo for is EAS, builds and EAS Update (OTA), which has a free tier plus usage-based paid plans. So push delivery itself is free; budget instead for EAS build minutes and OTA bandwidth. Our OTA study has the EAS Update cost maths.
Do I need useFcmV1 with expo-server-sdk?
The legacy FCM API is retired, so Android delivery now goes over FCM v1. Recent Expo SDKs default to it, so with `expo-server-sdk` you usually set nothing, but you must have uploaded an FCM v1 service-account key (not the old server key) via `eas credentials`. A missing or legacy credential is the usual cause of a sudden Android-only delivery drop with MismatchSenderId.
Expo push notifications vs Firebase Cloud Messaging, which should I use?
They are layers, not competitors. The Expo Push API sits in front of FCM (Android) and APNs (iOS) and handles credentials, batching and a single token format for you. Go direct to FCM/APNs only when you need something Expo does not expose (custom FCM options, non-Expo native modules); for almost every React Native app the Expo Push API is far less to maintain.

Our clients

UK · Europe · Worldwide

Selected case studies

What we built, how it works and the results for our clients.

Creoate product interface01
B2B commerce

Eight years behind a wholesale marketplace

Next.js storefront, Python ingestion pipelines, DynamoDB data layer and AWS infrastructure.

8+ yearsdevelopment and support
Ontick product interface02
Event technology

Ticketing owned by the event team

Multi-organiser commerce, Stripe instalments and two native apps in one connected platform.

£2M+ticket sales processed
Easyship product interface03
Global logistics

Helping shippers compare their options

Rate, tax and duty calculators, server-rendered courier pages and a custom MongoDB CMS.

550+couriers in the calculator
TEFL.ie product interface04
Education & training

Connecting course sales to the classroom

WordPress and WooCommerce, a Moodle LMS, Stripe deposits and Zoho CRM, tied together with Zapier automation.

Since 2017development and support
All White Laser product interface05
Medical aesthetics

From equipment finance to clinic support

A lead-to-billing system on GoCardless Direct Debit, provider certification, and a React Native app for machine owners.

9 yrsdevelopment and support
Decofetch product interface06
Luxury commerce

A custom home for designer furniture

Server-rendered Next.js commerce over a Laravel API, bespoke operations tooling and re-architected AWS infrastructure.

0→livemarketplace development
BA Engine Room product interface07
AI operations

Connecting discovery, contracts and delivery

Discovery briefs, e-signed contracts, Stripe deposits, delivery milestones and time tracking in one operational system.

0→1custom platform development
PlusHeat product interface08
Home services

Helping customers choose their boiler cover

Custom plan configuration, postcode-qualified lead journeys, CRM synchronisation and campaign landing pages.

5 yrswebsite development and support
Léonia product interface09
Beauty commerce

Shopify shaped around a beauty brand

Custom theme, customer accounts, loyalty rewards, referrals and gift-with-purchase offers.

5 yrsShopify development and support
Shutters 365 product interface10
Home improvement

From window measurements to a priced order

A seven-step product builder with live previews, sample orders and supplier tools.

7-stepproduct configurator
Bloc Ads Manager product interface11
Advertising

From targeted ads to venue check-ins

Campaign creation, audience targeting, in-app ads and reporting linked to venue check-ins.

check-inscampaign attribution
Bloc product interface12
Social events

Four years across the app and operations

Mobile app, backend, advertising tools, a digital marketplace and website.

4+ yrssupport across five codebases
Zonely product interface13
Social mobile

Two apps, one real-time conversation marketplace

Customer and buddy apps with per-minute billing, wallets, moderation and admin tools.

2 appsfor iOS and Android
Player Profile Hub product interface14
Grassroots football

Helping grassroots players get discovered

Verified profiles, video highlights, coach discovery and safeguarding on web and mobile.

0→1custom platform development
DeepSpatial product interface15
Geospatial AI

Connecting clients, investors and emerging talent

Corporate and investor pages, the Xploor talent platform and ongoing releases on AWS Amplify.

2 yrsdevelopment and support
Yippee Malta product interface16
Travel

A booking journey the tour team owns

A multilingual website connected to the booking API, with deposits, coupons and affiliate tracking.

6languages across the booking journey
Professional Energy product interface17
Energy brokerage

Tenders, contracts and accounts brought together

Supplier tenders, contract management, brokerage accounting and client records.

100+suppliers per tender

Tell us what you are trying to build.

A thirty-minute call with the engineer who would run it.