Field guide

Push notifications on Expo + FCM + APNs, the setup that actually delivers

The 30-minute setup, the end-to-end delivery chain, and a catalog of the twelve failure modes we see most often 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.

By Ritesh + PrinceMay 15, 202622 min read
Push notification delivery chain across Expo, FCM and APNs

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.

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.

The engagements this writing comes from

UK · Europe · Worldwide

selected work

Where these numbers came from

Production systems with the delivery figures attached.

Creoate product interface01
B2B commerce

The engineering partnership behind a cross-border wholesale marketplace.

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

8+ yearsone team, still shipping
Ontick product interface02
Event technology

A commission-free ticketing platform built for ownership and scale.

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

£2M+processed since launch
Easyship product interface03
Global logistics

Embedded product engineering for a global shipping platform.

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

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

A course-commerce and learning platform wired into one system.

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

8 yrsengineer & run, since 2017
All White Laser product interface05
Medical aesthetics

A bespoke platform that finances aesthetic machines on Direct Debit.

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

9 yrsbuild & run, since 2017
Decofetch product interface06
Luxury commerce

A custom furniture marketplace engineered from storefront to infrastructure.

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

0→livecustom, front to back
BA Engine Room product interface07
AI operations

An AI-native operating system that runs a consultancy lead to invoice.

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

0→1built from the ground up
PlusHeat product interface08
Home services

A conversion platform for a growing UK boiler-cover provider.

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

5 yrsweb partner since 2021
Léonia product interface09
Beauty commerce

A custom Shopify store for a French beauty brand.

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

5 yrspartners since 2021
Shutters 365 product interface10
Home improvement

Made-to-measure shutters with live pricing.

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

7-stepconfigurator, live pricing
Bloc Ads Manager product interface11
Advertising

A self-service advertising platform for venues.

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

check-insclosed-loop attribution
Bloc product interface12
Social events

An events app with the tools to run it.

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

4+ yrsone team, five codebases
Zonely product interface13
Social mobile

Two mobile apps for real-time companionship.

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

2 appsconsumer + buddy, iOS & Android
Player Profile Hub product interface14
Grassroots football

Player profiles and discovery for youth football.

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

0→1built from the ground up
DeepSpatial product interface15
Geospatial AI

Websites and a talent platform for DeepSpatial.

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

2 yrsone team, ongoing
Yippee Malta product interface16
Travel

Tour bookings with a custom mobile-first checkout.

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

90+core web vitals, mobile & desktop
Professional Energy product interface17
Energy brokerage

One platform for tenders, contracts and accounts.

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

100+suppliers in one tender

Tell us what you are trying to build.

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

Book a call