July 10, 2025

Handling Push Notifications Across Foreground, Background, and Terminated States on iOS (React Native)

iOS treats a push notification completely differently depending on what the app is doing when it arrives — open and in the foreground, backgrounded, or fully terminated. Getting one of those three right is easy. Getting all three right, with a badge count that’s actually correct in every case, took real work across two feature branches using Firebase Cloud Messaging (@react-native-firebase/messaging) and Notifee for local notification display.

Wiring Firebase in at the native layer

Before any JavaScript runs, AppDelegate.m needs Firebase configured and, in this case, iOS Background Fetch registered so the app gets a chance to sync while backgrounded:

#import "AppDelegate.h"
#import <Firebase.h>
#import <TSBackgroundFetch/TSBackgroundFetch.h>

@implementation AppDelegate

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
  [FIRApp configure];
  RCTBridge *bridge = [[RCTBridge alloc] initWithDelegate:self launchOptions:launchOptions];
  RCTRootView *rootView = [[RCTRootView alloc] initWithBridge:bridge
                                                   moduleName:@"YourApp"
                                            initialProperties:nil];
  self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
  self.window.rootViewController = [UIViewController new];
  self.window.rootViewController.view = rootView;
  [self.window makeKeyAndVisible];

  // [REQUIRED] Register BackgroundFetch
  [[TSBackgroundFetch sharedInstance] didFinishLaunching];

  return YES;
}

- (void)application:(UIApplication *)application performFetchWithCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler
{
  completionHandler(UIBackgroundFetchResultNewData);
}

The background handler has to live outside React

This is the part that’s easy to get wrong: Firebase’s background message handler has to be registered at the very top of the JS entry point, outside any React component, before the app even renders. If it’s registered inside a component, it may not be attached in time for a background push to trigger it — the OS can wake the JS engine in headless mode specifically to run this handler, and it needs to already exist.

// App.js
import messaging from '@react-native-firebase/messaging';
import notifee from '@notifee/react-native';

messaging().setBackgroundMessageHandler(async remoteMessage => {
  const badge = remoteMessage?.data?.badge_count
    ? parseInt(remoteMessage.data.badge_count)
    : null;
  if (badge !== null) {
    notifee.setBadgeCount(badge);
  }
});

export default () => (
  // app tree
);

Note what it does not do — it doesn’t try to display anything. On iOS, a background push with a notification payload is displayed by the OS automatically. This handler exists purely to keep the badge count correct even when the app never becomes active.

Foreground: the OS won’t show it for you

The opposite problem shows up when the app is open. A foreground push doesn’t get auto-displayed by iOS at all — if you want the user to see anything, you have to build and show a local notification yourself:

export function registerListenerWithFCM() {
  const unsubscribe = messaging().onMessage(async remoteMessage => {
    const badge = remoteMessage?.data?.badge_count
      ? parseInt(remoteMessage.data.badge_count)
      : null;
    if (badge !== null) {
      notifee.setBadgeCount(badge);
    }

    // The push payload only carries an ID — fetch the actual content before displaying
    const itemId = remoteMessage.data?.item_id;
    const response = await getItemById({ itemId });

    if (remoteMessage?.notification?.title && response?.description) {
      onDisplayNotification(
        remoteMessage.notification.title,
        response.description,
        remoteMessage.data,
      );
    }
  });

  return unsubscribe;
}

export async function onDisplayNotification(title, body, data) {
  await notifee.requestPermission();
  const channelId = await notifee.createChannel({ id: 'default', name: 'Default Channel' });

  await notifee.displayNotification({
    title,
    body,
    data,
    android: { channelId, pressAction: { id: 'default' } },
  });
}

The push payload deliberately stays thin — just enough to identify what changed, not the full content — and the client fetches the real description before showing anything. That keeps sensitive content out of the push payload itself (APNs payloads pass through Apple’s servers) and means the notification body can’t go stale if the underlying item changes between when the push was queued and when it’s actually delivered.

Three states, three listeners

The tap-handling side needs a separate listener for each of the three states a notification can be interacted with from:

// Foreground: user taps while the app is already open
notifee.onForegroundEvent(({ type, detail }) => {
  switch (type) {
    case EventType.PRESS:
      navigate(APP_ROUTE.NOTIFICATION);
      break;
    case EventType.DISMISSED:
      // user swiped it away — nothing to do
      break;
  }
});

// Background: app was backgrounded, user tapped the OS notification to bring it forward
messaging().onNotificationOpenedApp(async remoteMessage => {
  const badge = remoteMessage?.data?.badge_count ? parseInt(remoteMessage.data.badge_count) : null;
  if (badge !== null) {
    notifee.setBadgeCount(badge);
  }
});

// Terminated: app was fully killed, user tapped a notification to cold-launch it
messaging().getInitialNotification().then(remoteMessage => {
  const badge = remoteMessage?.data?.badge_count ? parseInt(remoteMessage.data.badge_count) : null;
  if (badge !== null) {
    notifee.setBadgeCount(badge);
  }
});

onMessage and onNotificationOpenedApp only ever fire while the JS runtime is already alive. getInitialNotification is the only one of the three that can tell you “the app was launched because of a notification tap” rather than a normal cold start — skip it, and a terminated-state tap just opens the app to its default screen with no idea a notification was involved.

Keeping the badge count honest

The badge count gets written through three different paths, and all three needed to agree:

  • notifee.setBadgeCount(count) — the actual OS-level icon badge, used from the background handler and both notification-opened listeners
  • PushNotificationIOS.setApplicationIconBadgeNumber(count) — a second iOS badge API, used from the in-app counter update path
  • A Redux setNotificationCount dispatch — so any in-app UI (a bell icon, a tab badge) reflects the same number without polling
export const updateBadgeCount = (count = 0) => {
  store.dispatch(setNotificationCount(count));
  if (Platform.OS === 'ios') {
    PushNotificationIOS.setApplicationIconBadgeNumber(count);
  }
};

The count also gets resynced whenever the app transitions back to the active AppState, since a push can arrive, get partially processed by the background handler, and then the user opens the app through the app icon rather than the notification — in which case none of the three listeners above ever fire at all.

Device tokens have a lifecycle too

Getting a token isn’t a one-time thing — it has to be requested, persisted, sent to the backend tied to a specific device, and cleaned up on logout so a signed-out device stops receiving pushes meant for the account that just logged out:

export const getFcmToken = async (userUuid, authToken) => {
  try {
    const deviceUuid = await DeviceInfo.getUniqueId();
    let token = await AsyncStorage.getItem('fcmToken');

    if (!token) {
      await checkApplicationNotificationPermission();
      await registerAppWithFCM();
      token = await messaging().getToken();
      await AsyncStorage.setItem('fcmToken', token);
    }

    await saveFcmDeviceToken({
      tokenCSRF: authToken,
      token,
      userUuid,
      deviceType: Platform.OS,
      deviceUuid,
    });
    return token;
  } catch (error) {
    Sentry.captureException(error?.message, { tags: { section: 'FcmDeviceTokenSave' } });
    return null;
  }
};

export const removeDeviceToken = async (userUuid, authToken) => {
  const token = await AsyncStorage.getItem('fcmToken');
  if (!token) return;

  const deviceUuid = await DeviceInfo.getUniqueId();
  await removeFcmDeviceToken({
    tokenCSRF: authToken,
    userUuid,
    deviceType: Platform.OS,
    deviceUuid,
  });
};

The device UUID matters as much as the token — a user can be logged into the same account on a phone and a tablet, and the backend needs to tell those two registrations apart to send (or stop sending) pushes to the right one specifically, rather than treating “one user” as “one notification target.”

What made this hard

None of the individual pieces are exotic — Firebase’s docs cover each listener in isolation. What actually took the time was that iOS gives you three genuinely different code paths for what feels like one feature, plus a badge count that three separate APIs each have their own opinion about, and none of that shows up until you’ve actually killed the app, force-quit it, backgrounded it mid-request, and tapped a notification from each state to see what breaks.