Push Notifications in React Native 2026: Expo Push, FCM HTTP v1, and APNs Tokens That Silently Expire
Push looks simple until 30% of your Android sends fail overnight and iOS silent notifications stop waking your app. Here's how the stack actually breaks in 2026 and how we wire it.

Push notifications look like a solved problem until you ship to a real user base. Then you notice the Android delivery rate quietly slid from 96% to 71% over a weekend, iOS silent pushes stopped waking the app after an OS update, and the backend team is staring at a 403 from Google that didn't exist last month. In 2026, the moving parts around React Native push are FCM HTTP v1, APNs token-based auth, and Expo's push service on top — and each layer has a specific way of failing.
This is the setup we run for clients, what we've watched break in the last twelve months, and the pieces we'd wire up first on a new project.
The 2026 stack, honestly
If you're building on Expo (SDK 52+), your realistic options are:
- Expo Push Service → wraps FCM + APNs, gives you one token format, one send API.
- Direct FCM HTTP v1 + APNs → you manage two token types, two send paths, two retry strategies.
- A vendor (OneSignal, Airship, Customer.io) → they wrap the same two providers, plus segmentation.
Expo Push is not a magic trick. Under the hood it still calls FCM and APNs, so every constraint those providers have applies to you. What Expo gives you is a normalized ExpoPushToken, receipts, and batching. What it costs you is a hop in the delivery path and slightly less control over payload shape.
Our default: Expo Push for MVPs and mid-size apps, direct FCM + APNs when you cross roughly 500k daily active devices or need very tight payload control (e.g. VoIP, Live Activities, or provisional-authorization patterns Expo doesn't fully expose yet).
The FCM HTTP v1 migration nobody finished
Google turned off the legacy FCM API (fcm.googleapis.com/fcm/send) in mid-2024. Everyone knows this. What we still see in 2026 is server code that migrated the send call but never migrated the auth model. Legacy used a static server key. HTTP v1 uses a short-lived OAuth 2.0 access token you mint from a service account JSON.
If your backend caches that access token forever, it will work for an hour and then start returning 401. If it re-mints on every send, you'll rate-limit yourself and burn CPU. The right shape is a token cache with a refresh window:
import { GoogleAuth } from 'google-auth-library';
const auth = new GoogleAuth({
credentials: JSON.parse(process.env.FCM_SERVICE_ACCOUNT!),
scopes: ['https://www.googleapis.com/auth/firebase.messaging'],
});
let cached: { token: string; expiresAt: number } | null = null;
export async function getFcmAccessToken() {
const now = Date.now();
if (cached && cached.expiresAt - now > 5 * 60_000) {
return cached.token;
}
const client = await auth.getClient();
const res = await client.getAccessToken();
cached = {
token: res.token!,
// Google tokens are ~1h; refresh 5 min early
expiresAt: now + 55 * 60_000,
};
return cached.token;
}
Refresh five minutes before expiry, not on 401. Reacting to 401 means every worker in your fleet stampedes the token endpoint at the same second.
APNs: token auth or you're on borrowed time
If you're still using APNs certificate-based auth in 2026, stop. Certificates expire yearly and you will forget. Token-based auth (a .p8 key + key ID + team ID) doesn't expire, works across all your apps under the same team, and is what every mature backend runs.
The subtle failure: the JWT you sign for APNs is valid for up to an hour. Apple documents this. What Apple doesn't shout about is that if you regenerate the JWT more often than roughly once every 20 minutes from the same server, you can trip a rate limit and get told your provider token is invalid — which reads like a config bug but is actually a throttle. Sign once, cache for ~45 minutes, rotate.
Silent pushes after iOS updates
Every major iOS release quietly tightens background execution. Silent pushes (content-available: 1, no alert, no sound) are the first thing to get throttled if the user rarely opens your app, if the device is in Low Power Mode, or if your app has a history of doing heavy work in didReceiveRemoteNotification.
In our experience, silent-push delivery to "cold" users on recent iOS versions can be as low as 40–60% within any given hour. Design assuming they're best-effort. If you need reliable state sync, silent push wakes the app to check, but the actual sync should be idempotent and driven by a server-truth pull.
Android: the delivery rate mystery
Android push delivery is now dominated by three things: OEM battery managers, notification channels, and FCM's own priority system.
- Xiaomi, Oppo, Vivo, Huawei aggressively kill background apps unless the user explicitly whitelists them. Your delivery rate on those devices will always look worse than Pixel/Samsung. That's not a bug in your code.
- Notification channels are mandatory since Android 8, but on Android 13+ the user must also grant
POST_NOTIFICATIONS. If you request it on first launch with no context, denial rates in our projects sit around 40–55%. Ask after the user has done something that makes push obviously useful (placed an order, followed a creator, set a price alert). - FCM priority:
highpriority wakes the device;normalmay be batched. Google will throttle apps that abusehighfor non-user-visible messages. If every push you send ishighand half of them are silent data syncs, expect delivery to degrade over weeks, not minutes.
Wiring it up in Expo
The Expo side is genuinely straightforward once the server is right. What we standardize on every project:
import * as Notifications from 'expo-notifications';
import * as Device from 'expo-device';
import { Platform } from 'react-native';
export async function registerForPush(userId: string) {
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: 'General',
importance: Notifications.AndroidImportance.DEFAULT,
vibrationPattern: [0, 250, 250, 250],
});
}
const token = (
await Notifications.getExpoPushTokenAsync({
projectId: process.env.EXPO_PUBLIC_PROJECT_ID,
})
).data;
await fetch('/api/devices', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
userId,
token,
platform: Platform.OS,
appVersion: Constants.expoConfig?.version,
}),
});
return token;
}
Two things this deliberately does:
- Sends
platformandappVersionwith the token. When a push campaign misfires, you want to slice by app version in seconds. - Registers a channel before asking for the token on Android, so the first notification the user sees isn't silently mis-categorized.
Token rotation is the bug you'll ship
Expo push tokens rotate. Not often, but they do — after reinstall, after certain OS restores, and occasionally for reasons Apple and Google don't explain. If your backend treats a token as immutable per user, you'll accumulate dead tokens and your delivery-rate dashboard will lie to you.
On every app launch, re-fetch the token and PATCH it to the server. Also honor Expo push receipts: they'll tell you DeviceNotRegistered, and that's your signal to hard-delete the token, not retry.
App review pitfalls specific to push
A few we've hit repeatedly:
- Requesting permission on launch with no explanation. Apple's reviewers reject this under 4.5.4 more often in 2026 than in prior years. Show a soft prompt screen first.
- Marketing pushes without a settings toggle. Both stores now expect a granular opt-out inside the app, not just at the OS level.
- Silent push used for tracking. If your
content-availablepayload triggers analytics or ad SDK work with no user-visible outcome, that's a rejection risk on iOS and an ATT gray area.
What we'd do on a new project
On day one: Expo Push, token-based APNs auth, FCM HTTP v1 with a cached OAuth token, a devices table keyed by (userId, token) with a last_seen_at column, and a nightly job that deletes tokens unseen for 60 days. Ask for notification permission after the first meaningful action, not on launch. Log delivery receipts and slice by OS version and app version from day one — because when the delivery rate drops, you want the answer in a query, not a week of guessing.
If you're stuck on any of this or planning a migration off a legacy push stack, our mobile team has done this walk enough times to know where the potholes are.
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: Face ID, Android Keystore, and the Silent Lockouts Nobody Warns You About
Face ID and fingerprint auth look like a two-line integration until users start getting locked out after an OS update. Here's how we ship biometric login in React Native without the 3am support tickets.

EAS Update in 2026: When OTA Saves You and When It Gets You Rejected
OTA updates are the best part of Expo — until they aren't. A field guide to what you can safely ship over-the-air in 2026, what will get your app pulled, and how we structure release channels to sleep at night.
In-App Purchases in React Native: RevenueCat, StoreKit 2, and the Receipts That Lie
A practical breakdown of wiring IAP and subscriptions into a React Native app in 2026 — what RevenueCat actually saves you, where StoreKit 2 still bites, and the receipt edge cases that cost real revenue.
