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.
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 eventFive 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
{
"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)
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;
}3. Sending (backend)
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);
}
}
}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.
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
}
}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; high priority 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 everyoneexcept 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.
■ Related research
Related research
Two mobile-cluster companions, and one that covers the upstream cause of misconfigured push setups on AI-built prototypes:
■ Related services
Three engagements that ship this work
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.

About the author
Ritesh — Founding Partner, Appycodes
LinkedInCo-authored with Prince Sharma, Lead React Native Engineer
Ritesh leads engineering at Appycodes. Prince leads the React Native practice day-to-day and owns the push pipeline shape on the 47 RN apps we ship and maintain. The reconciliation cron, the device-side receipt instrumentation, and the per-category priority routing are the three changes that have moved delivery rates the most on apps where push went quietly broken.
