Push Notifications in Expo and React Native: Why Yours Are Silently Failing in 2026
Push delivery looks fine in your dashboard but users swear they never got the notification. Here's what actually breaks push on iOS and Android in 2026, and how to debug it before support tickets pile up.

Push notifications are the one feature where "works in dev" means almost nothing. A delivery dashboard that says 100% success can sit next to a support inbox full of "I never got the alert," and both can be technically correct. In 2026, the gap between accepted by the provider and seen by the user is wider than it has ever been, and most of the silent failures we debug for clients trace back to the same dozen causes.
This is the field guide we wish we'd had when we first shipped an Expo app to a five-figure user base.
The mental model that prevents most of these bugs
A push notification passes through at least four owners before it lights up a screen:
- Your server (or Expo's push service) builds the payload.
- The provider — APNs for iOS, FCM HTTP v1 for Android — accepts or rejects it.
- The OS decides whether to display it, drop it, batch it, or hand it to your app silently.
- Your app (if foregrounded, or for data-only pushes) decides what to do with it.
Every step has its own definition of "success." Expo's push receipts confirm step 2. They do not confirm steps 3 or 4. This is the single most common source of "but the dashboard says it was delivered" tickets.
Why this got worse in 2026
Three things shifted recently and broke a lot of older tutorials:
- FCM legacy API is fully gone. If any part of your stack still references the legacy server key, it is silently doing nothing. FCM HTTP v1 with OAuth2 service accounts is the only path.
- iOS now aggressively throttles non-critical pushes for apps the user rarely opens, as part of the broader Focus and Notification Summary system.
- Android 15+ enforces stricter notification permission and channel behavior, and OEM skins (especially on devices common in India, Southeast Asia, and Latin America) layer their own battery optimization on top.
The iOS failure modes we see most
Token-based APNs auth misconfigured
If you're using Expo's managed push service, this is handled. If you moved to direct APNs (which we recommend once you're past ~50k DAU for cost and control), token-based auth with a .p8 key is the only sane choice. The failure mode: the key works in staging, then quietly stops in production because the team ID or key ID got swapped during a credentials rotation.
Log the full APNs response. A 403 InvalidProviderToken does not crash anything; it just means zero deliveries.
The apns-push-type header
Apple requires this header and has for years, but Expo's older docs and some community libraries still let you omit it. The valid values that matter for most apps:
apns-push-type: alert // user-visible notification
apns-push-type: background // silent, content-available
apns-push-type: voip // CallKit only
Get this wrong and iOS will drop the push without telling you. Background pushes also require content-available: 1 and apns-priority: 5 — priority 10 with content-available is a guaranteed silent drop.
Notification Summary and Focus modes
This one is unfixable from your side, but you need to know it exists. If a user has scheduled summary on, your non-time-sensitive pushes get held until their summary window. They will swear the push never arrived. The fix is to mark genuinely urgent notifications with the time-sensitive interruption level:
{
"aps": {
"alert": { "title": "Driver is 2 min away" },
"interruption-level": "time-sensitive",
"sound": "default"
}
}
Don't abuse this. Apple has started flagging apps that mark everything time-sensitive during review.
The Android failure modes we see most
Notification channels created after the first push
On Android 8+, every notification must belong to a channel. If you create the channel lazily on first notification receipt, the first push for new users often fails to display because the channel doesn't exist yet at the moment the system handler fires. Create channels at app startup, not in your push handler.
import * as Notifications from 'expo-notifications';
import { Platform } from 'react-native';
export async function registerChannels() {
if (Platform.OS !== 'android') return;
await Notifications.setNotificationChannelAsync('default', {
name: 'General',
importance: Notifications.AndroidImportance.DEFAULT,
sound: 'default',
});
await Notifications.setNotificationChannelAsync('urgent', {
name: 'Time-sensitive alerts',
importance: Notifications.AndroidImportance.HIGH,
sound: 'default',
enableVibrate: true,
});
}
Call registerChannels() from your root component's mount effect, before you ask for permission.
OEM battery killers
Xiaomi, Oppo, Vivo, Huawei, Samsung, and a few others all ship aggressive background-process killers that ignore Google's guidance. If your app gets force-stopped by the OEM, FCM will deliver to a closed pipe and the push will never appear. There is no clean code fix. What works:
- Detect first launch on a high-risk OEM and show a one-time screen explaining how to whitelist your app from battery optimization.
- Use a high-priority FCM message (
priority: high) only for genuinely urgent payloads — overuse gets you throttled. - For business-critical pushes, fall back to SMS or email after a delivery timeout.
POST_NOTIFICATIONS permission
On Android 13+ this is a runtime permission, and Android 15 enforces it more strictly. If you ask too early in onboarding, users say no and you have no good path back without sending them to system settings. Ask in context — right before the first notification would actually be useful.
How to debug a "this user isn't getting pushes" ticket
A repeatable checklist beats guessing every time. This is roughly what we run when a client opens a P2:
- Confirm the device token is current. Tokens rotate. Did the server store the latest one? When was the last
registerForPushNotificationsAsynccall? - Check Expo push receipts (if using Expo's service) for the specific
ticketId.DeviceNotRegisteredis the most common — it means the token is dead and you need to stop sending to it. - For direct APNs, log the full response status and
apns-id. Apple's feedback service tells you about uninstalls. - For FCM HTTP v1, inspect the JSON response.
UNREGISTEREDandINVALID_ARGUMENTare your top two. - Ask the user about Focus mode, Do Not Disturb, summary, and battery optimization. Most of the time it's one of these.
- Send a test push from the device itself (a debug screen that calls your send endpoint). This isolates server vs. client.
We keep a hidden debug screen in every production app that surfaces the current token, the last 10 received notifications, and a "send me a test push now" button. It has paid for itself many times over.
When to leave Expo's push service
Expo's push service is genuinely good and we keep apps on it well past the point most teams assume they need to migrate. The actual triggers for moving to direct APNs and FCM:
- You need analytics per push that Expo doesn't expose (open rates by segment, A/B variants).
- You're sending more than a few million pushes per month and want to control retry behavior.
- You need rich notifications with custom decryption or mutable content that does heavy work.
- Compliance requires that payloads never traverse a third-party service.
If none of those apply, stay on Expo. The engineering hours you save are real. If you do migrate, do it for one platform at a time and keep the Expo path as a fallback for a release or two.
Server-side patterns that save you later
A few things we now build into every push pipeline by default:
- Idempotency keys on send. Retries are inevitable; duplicate pushes are a support nightmare.
- Per-user send quotas. A buggy loop should not be able to send a user 4,000 notifications.
- A dead-token reaper. When you get
DeviceNotRegisteredorUNREGISTERED, soft-delete the token immediately. Sending to dead tokens skews your delivery metrics and, at scale, gets you rate-limited. - A "would have sent" log in staging. Every push that would go out gets logged with the rendered payload. Catches localization bugs before they hit prod.
Where we'd start
If you're staring at a flaky push setup right now, do three things this week. First, add a debug screen that shows the current token and lets you trigger a test send — you'll use it constantly. Second, audit your server logs for the last 24 hours of provider responses and count the error codes; the distribution will tell you whether you have a token-hygiene problem or a payload problem. Third, write down which of your notifications genuinely deserve time-sensitive or priority: high and which don't, then enforce that list in code so nobody can quietly promote a marketing blast.
If you want a second pair of eyes on a push pipeline before it ships, that's the kind of thing our mobile team does most weeks.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading

Biometric Auth in React Native 2026: Face ID, Passkeys, and the Keychain Traps
Face ID and fingerprint look simple from the product side. The keychain semantics, passkey fallbacks, and review-time edge cases are where React Native teams lose a week. Here's what we've learned shipping it.

EAS Update OTA Strategy in 2026: Channels, Rollouts, and Rollbacks Without Breaking Review
OTA updates are the best superpower Expo gives you — and the easiest way to get your app pulled. Here's how we structure EAS Update channels, staged rollouts, and rollbacks without tripping Apple's review rules.

In-App Purchases in React Native: What Apple and Google Actually Reject in 2026
A practical breakdown of the IAP rejection patterns we keep seeing in React Native and Expo apps in 2026 — from receipt validation to subscription restore flows — with code you can lift straight into your project.
