Push Notifications in React Native 2026: APNs, FCM HTTP v1, and the Silent Delivery Failures
Push looks simple until you ship. Here's how APNs tokens, FCM HTTP v1, Expo's push service, and iOS focus modes conspire to eat your notifications — and what to actually do about it.
Push notifications are the feature product managers assume takes an afternoon and engineers know takes three sprints. In 2026, with FCM's legacy API fully retired, iOS focus modes eating more traffic than most teams realise, and Expo's push service now sitting in front of a lot of production apps, the surface area for silent failure is bigger than ever.
This is the breakdown we wish we had before shipping our last three React Native apps.
The stack you're actually running in 2026
If you're on React Native with Expo (managed or bare with prebuild), your push pipeline usually looks like this:
- Your app requests permission and gets a device token.
- That token is either an APNs token (iOS), an FCM registration token (Android), or an Expo push token that wraps both.
- Your backend sends a payload to either APNs directly, FCM HTTP v1, or
exp.host/--/api/v2/push/send. - Apple or Google's infrastructure decides whether, when, and how loudly to deliver it.
That last step is where most of the pain lives. You do not control it, you cannot really debug it, and the failure modes are mostly silent.
Expo push vs. talking to APNs/FCM yourself
Expo's push service is a thin fan-out layer. It takes an Expo push token, unwraps it, and forwards to APNs or FCM. It's free, it batches well, and it gives you a receipts endpoint. The tradeoffs:
- Pro: one token format, one API, one payload shape. You skip APNs auth keys and FCM service accounts in your backend.
- Con: an extra hop. If Expo's service has a hiccup (rare, but it happens), your delivery latency goes up.
- Con: some advanced payload features (critical alerts, certain rich notification fields, live activities) still need direct APNs.
For most B2C apps, Expo push is the right call for the first year. When you need Live Activities, critical alerts, or per-tenant APNs keys, migrate to direct APNs on the iOS side and keep Expo push (or FCM HTTP v1) on Android.
FCM HTTP v1: the migration everyone put off
Google's legacy FCM API is gone. If your backend still imports something that looks like firebase-admin from 2021 and posts to fcm.googleapis.com/fcm/send, it's been dead for a while. HTTP v1 is the only game in town.
The practical differences that trip teams up:
- OAuth 2.0 service account, not a server key. You mint short-lived access tokens from a JSON credential file.
- Per-platform payload nesting.
android,apns, andwebpushare separate objects. Copy-pasting an old flat payload will silently drop fields. - Stricter validation. Invalid tokens now return
UNREGISTEREDimmediately instead of eventually.
A minimal Node backend that sends via HTTP v1:
import { GoogleAuth } from 'google-auth-library';
const auth = new GoogleAuth({
keyFile: process.env.FCM_SERVICE_ACCOUNT_PATH,
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.FCM_PROJECT_ID;
const url = `https://fcm.googleapis.com/v1/projects/${projectId}/messages:send`;
const message = {
message: {
token,
notification: { title, body },
android: {
priority: 'HIGH',
notification: { channel_id: 'default' },
},
apns: {
headers: { 'apns-priority': '10' },
payload: { aps: { sound: 'default', 'content-available': 1 } },
},
},
};
const res = await client.request({ url, method: 'POST', data: message });
return res.data;
}
Note the channel_id. Android 8+ requires a notification channel, and if you don't declare one that matches, the notification arrives but shows nothing. This is a top-three cause of "push doesn't work on Android" tickets in our experience.
The iOS silent failure taxonomy
iOS is where push gets weird. A notification can be accepted by APNs, delivered to the device, and never shown to the user. Here are the flavours we see most often:
Focus modes and notification summaries
Since iOS 15, and much more aggressively in recent versions, users have Focus modes that filter notifications by app or by importance. A user in Work Focus may never see your consumer app's push until 6pm — and your analytics will show "delivered" the whole time. There is no API to detect this.
Mitigation: for time-sensitive content, set interruption-level to time-sensitive in your APNs payload and add the com.apple.developer.usernotifications.time-sensitive entitlement. Users can still block it, but at least you're asking.
Low Data Mode and Low Power Mode
Both defer non-critical pushes. If you sent with apns-priority: 5, expect delivery to be batched, sometimes by hours. Use priority 10 for anything user-facing.
The content-available trap
Setting content-available: 1 turns your push into a background push. iOS throttles these aggressively — Apple's documented budget is roughly 2–3 per hour, and in practice it's often less. If you send a normal alert with content-available: 1 bolted on, iOS may treat the whole thing as background and delay it.
Rule of thumb: alerts and background pushes are two different payloads. Don't mix them.
Provisional authorisation and its quiet consequences
Provisional auth lets you send notifications without asking permission, but they go straight to the Notification Centre — no banner, no sound. Great for onboarding. Terrible if you forgot you enabled it and are wondering why nobody sees anything.
Token lifecycle: the bug you'll ship at least once
Device tokens are not stable. They change when:
- The user restores from backup to a new device.
- The app is uninstalled and reinstalled.
- The user resets their device.
- Occasionally, APNs just rotates them.
Every React Native push guide tells you to save the token on login. Almost none tell you to re-check it on every cold start and update your backend if it changed. If you skip that, your delivery rate slowly rots — 2% a month is a number we've seen.
import * as Notifications from 'expo-notifications';
import { useEffect } from 'react';
export function usePushTokenSync(userId: string | null) {
useEffect(() => {
if (!userId) return;
(async () => {
const { status } = await Notifications.getPermissionsAsync();
if (status !== 'granted') return;
const token = (await Notifications.getExpoPushTokenAsync()).data;
const stored = await getStoredToken(userId);
if (token !== stored) {
await api.post('/devices', { userId, token, platform: Platform.OS });
await setStoredToken(userId, token);
}
})();
}, [userId]);
}
Also: handle the DeviceNotRegistered and UNREGISTERED errors from the push receipts endpoints. Those are your signal to purge dead tokens. If you don't, your "sent" numbers stay high while actual delivery quietly drops.
Permission prompts and the App Review angle
Both stores have tightened up on push permission UX.
- Do not prompt on first launch with no context. Reviewers reject this, and users deny it.
- Do use a soft prompt (your own screen explaining value) before the system prompt. Once denied, iOS won't let you ask again from within the app — the user has to go to Settings.
- Do respect the fact that a user who denied push is not a user who wants a modal every session asking them to reconsider.
On Android 13+, POST_NOTIFICATIONS is a runtime permission. Expo handles the request, but you still need to justify it in your app if a reviewer asks.
Testing push properly
The iOS simulator can receive pushes since Xcode 14 — drag an .apns file onto it. Use this in CI for basic smoke tests. But nothing replaces a real device matrix:
- One iPhone with Focus mode on.
- One Android with Battery Saver on.
- One device with the app force-quit (behaves differently on both platforms).
- One device offline for 10 minutes, then back online (tests APNs and FCM retry windows).
Build a /debug/push endpoint in staging that lets QA send any payload shape to any registered device. It pays for itself in the first week.
Where we'd start
If you're greenfield in 2026: start with Expo push, use expo-notifications, and put your token sync logic on cold start rather than login. Split alert and background payloads from day one. Add a receipts poller that purges dead tokens weekly.
If you're auditing an existing app: check your FCM HTTP v1 migration, log delivery receipts for a week, and compare "sent" to "opened" by platform. If iOS opens are under 40% of sent, you almost certainly have a Focus mode and priority problem, not a code problem. Fix the payload before you fix anything else.
And if any of this sounds like a project you'd rather hand off, our mobile team does exactly this kind of work.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading
OTA Updates in Expo 2026: EAS Update, Rollbacks, and the App Store Rules That Bite
OTA updates are the best feature of Expo — and the fastest way to get your app pulled from review. Here's how we ship EAS Update safely in 2026, including rollback strategy and the store rules teams keep tripping on.

In-App Purchases in React Native 2026: RevenueCat, StoreKit 2, and the Receipts That Lie
IAP looks simple until refunds, family sharing, and grace periods hit production. Here's how we wire React Native purchases in 2026 without trusting the client.
App Size Bloat in React Native: Where the Megabytes Actually Come From in 2026
A field guide to shrinking React Native app binaries in 2026: Hermes bytecode, native modules, image assets, and the App Store Connect numbers that don't match what you built locally.
