Skip to content
/Push Notifications in React Native — FCM + APNs Done Right
Apr 2026·8 min read

Push Notifications in React Native — FCM + APNs Done Right

End-to-end push notification setup for React Native using Firebase Cloud Messaging on Android and APNs via FCM on iOS — covering token rotation, background handlers, deep links, notification channels, and the silent failures nobody documents.

React NativePush NotificationsFirebaseFCMAPNs
Text-to-Speech unsupported
Text Size

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.

iOS — AppDelegate.mm registration
#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.

Token bootstrap + rotation listener
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.

index.js — register the background handler
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).

Share X LinkedIn
NY
Neelesh Yadav
React Native Developer with 3 yr 6 mos years building production mobile apps. Writes about performance, architecture, and mobile engineering.
Follow ↗

Comments

Leave a comment

Comments are reviewed before they appear.

More Articles

Boosting React Native Performance: New Architectures Deep Dive
8 min read
Building Real-Time Chat with WebSocket in React Native
6 min read
AR Try-On: Three.js + MediaPipe in React Native
10 min read