Push is one of those features that looks simple in the demo and brutal in production. On Soul33 — a community-driven wellness app — push was load-bearing: sponsor messages, group chat replies, meditation reminders, and re-engagement. We shipped it via Firebase Cloud Messaging on both platforms, but every "happy path" tutorial skipped the parts that broke: token rotation after reinstall, iOS not registering APNs at all in TestFlight builds, and notifications swallowed silently by Doze mode on Android. This post is the version I wish I had.
Architecture — FCM for both platforms
Don't talk to APNs directly from your backend. Use Firebase as the single delivery surface — FCM forwards to APNs on iOS and delivers directly on Android. Your backend stores one device token per user per device, talks to one API, and a single sender key handles both platforms. The cost: you accept a few hundred milliseconds of FCM-side latency on iOS for the simplicity. For 99% of apps that's the right trade.
Step 1 — Native setup the tutorials skip
On iOS, enable Push Notifications and Background Modes → Remote notifications in Xcode capabilities. Then the part that bites: APNs auth keys (.p8) not certificates. Generate a single .p8 in the Apple Developer portal under Keys, upload it to Firebase Cloud Messaging → Apple app config with your Team ID and Key ID. Certificates expire yearly and break push in production overnight; .p8 keys don't. On Android, drop google-services.json under android/app/ and ensure your applicationId in build.gradle matches the Firebase Android app's package name exactly — a mismatch results in tokens that look valid but never receive messages.
#import <Firebase.h>
#import <UserNotifications/UserNotifications.h>
- (BOOL)application:(UIApplication *)application
didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
[FIRApp configure];
// Register for remote notifications — this is what triggers APNs token generation
UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];
center.delegate = self;
UNAuthorizationOptions options = UNAuthorizationOptionAlert
| UNAuthorizationOptionSound
| UNAuthorizationOptionBadge;
[center requestAuthorizationWithOptions:options
completionHandler:^(BOOL granted, NSError * _Nullable error) {
if (granted) {
dispatch_async(dispatch_get_main_queue(), ^{
[application registerForRemoteNotifications];
});
}
}];
return [super application:application didFinishLaunchingWithOptions:launchOptions];
}Step 2 — Token retrieval and rotation
FCM tokens are not static. They rotate when the user reinstalls the app, clears app data, restores from a backup on a new device, or — on iOS — when the user toggles notifications off and back on. Your backend must accept a token-update endpoint and the client must call it every time getToken() returns a value different from what's stored locally. Skipping this is why "my old phone still gets pushes after I reinstall on a new one" — both devices think they own the same user, only one is current.
import messaging from '@react-native-firebase/messaging';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { api } from '../api/client';
export async function bootstrapPush(userId: string) {
const authStatus = await messaging().requestPermission();
const enabled =
authStatus === messaging.AuthorizationStatus.AUTHORIZED ||
authStatus === messaging.AuthorizationStatus.PROVISIONAL;
if (!enabled) return;
const token = await messaging().getToken();
const cached = await AsyncStorage.getItem('fcm_token');
if (token !== cached) {
await api.post('/devices/register', { token, userId, platform: Platform.OS });
await AsyncStorage.setItem('fcm_token', token);
}
// Listen for FCM-initiated rotations (rare but real)
messaging().onTokenRefresh(async (newToken) => {
await api.post('/devices/register', { token: newToken, userId, platform: Platform.OS });
await AsyncStorage.setItem('fcm_token', newToken);
});
}Step 3 — Foreground vs background vs quit-state handling
RN Firebase fans out incoming messages across three states: foreground (onMessage — you must display the notification yourself, FCM won't), background (setBackgroundMessageHandler in index.js — runs in a headless task), and quit-state cold launch (getInitialNotification — returns the notification that woke the app). All three need to handle the same deep-link payload consistently. We solved this on Soul33 with a single navigation function that all three handlers call once the navigation ref is ready.
import messaging from '@react-native-firebase/messaging';
// MUST be outside any component, BEFORE AppRegistry.registerComponent
messaging().setBackgroundMessageHandler(async (remoteMessage) => {
// Persist to AsyncStorage; the app will pick it up on next launch
await AsyncStorage.setItem(
'pending_deep_link',
JSON.stringify(remoteMessage.data ?? {}),
);
});Step 4 — Android channels, iOS categories, deep links
On Android 8+, every notification must belong to a channel — register channels with distinct sound/vibration settings on first launch via notifee or @notifee/react-native. On iOS, register notification categories with actions (Reply, Mark as Read) the same way. Deep links live in remoteMessage.data — a flat object of string-only values. Never rely on FCM's notification.title/body for routing; those are display-only. Always include screen and entityId fields in data and route from there.
Why your test pushes never arrive
Top three silent failures we've debugged: (1) TestFlight builds need a Production APNs auth key — sandbox keys only work in development builds, so a fresh TestFlight install will register but never receive. (2) Android OEMs (Xiaomi, Oppo, Vivo) aggressively kill background processes — users must whitelist the app in battery settings or the FCM service is terminated. (3) priority: 'normal' messages get batched by FCM and may arrive minutes later — use priority: 'high' for chat and time-sensitive content, but reserve normal for marketing pings so you don't burn your sender reputation.
Production checklist: APNs .p8 key uploaded to Firebase (not a cert), token rotation endpoint deployed, all three handlers (foreground/background/quit) call the same deep-link router, Android notification channels registered before sending, priority set per message type, and a fallback for users who haven't granted permission (in-app inbox so they don't miss critical messages).