Scientyfic World

Push Notifications in React Native With Firebase Cloud Messaging (HTTP v1)

Firebase Cloud Messaging (FCM) delivers push notifications to Android and iOS apps for free. In a React Native app, the work splits in two: the app asks for permission, gets...

Share:

Get an AI summary of this article

Firebase Cloud Messaging

Firebase Cloud Messaging (FCM) delivers push notifications to Android and iOS apps for free. In a React Native app, the work splits in two: the app asks for permission, gets a device token and handles incoming messages; your server stores tokens and sends messages through the FCM HTTP v1 API with the Firebase Admin SDK. This guide builds both halves with React Native Firebase 26 and firebase-admin 14.

What I tested, and what I could not: the client code type-checks and lints in a React Native 0.87 project, and three Jest tests (with Firebase and the permissions library mocked) cover permission handling, token registration and refresh, and routing when a notification is tapped. The server’s send-and-cleanup logic passes two tests with a fake messaging client, and I checked the Admin SDK’s own error mapping for dead tokens. I did not send real notifications: that needs a real Firebase project, signing credentials and physical devices (iOS simulators on Apple Silicon do not receive real APNs tokens).

The 2023 version of this post described servers authenticating “using the server key”. That legacy API is gone: Google deprecated the legacy FCM APIs in June 2023 and began shutting them down on July 22, 2024. Sending now uses the HTTP v1 API with short-lived OAuth credentials, which the Admin SDK handles for you. The old post also missed the Android 13 notification permission.

How FCM Works

Firebase Cloud Messaging architecture: app server, FCM backend, platform transport and device
  1. The app registers with FCM and receives a registration token that identifies this app install.
  2. The app sends the token to your server, which stores it against the user.
  3. To notify someone, the server calls FCM with the user’s tokens and a message.
  4. FCM delivers the message: directly on Android, and through Apple Push Notification service (APNs) on iOS.
  5. The app shows it as a system notification (background or quit) or handles it in code (foreground).
Message typeContainsBehavior
Notification messagenotification: { title, body }, optional dataThe OS shows it automatically when the app is in the background or quit
Data messageOnly data (string key-values)Delivered to your code; you decide what to show

Step 1: Set Up Firebase and Native Credentials

  1. Create a Firebase project and add an Android app (download google-services.json) and an iOS app (download GoogleService-Info.plist).
  2. iOS: in your Apple Developer account, create an APNs authentication key (.p8) and upload it in Project settings > Cloud Messaging. Enable the Push Notifications capability and the “Remote notifications” background mode in Xcode.
  3. Install the libraries:
npm install @react-native-firebase/app @react-native-firebase/messaging react-native-permissions
cd ios && pod install

Follow the React Native Firebase setup for placing the config files. In an Expo app, use a development build and the libraries’ config plugins rather than Expo Go.

Step 2: Ask Permission and Register the Token

iOS always needs the user’s permission, and Android needs the runtime POST_NOTIFICATIONS permission from Android 13 (API 33). React Native Firebase marks its own requestPermission as deprecated in favor of a permissions library, so this uses react-native-permissions, which covers both platforms:

import { getMessaging, getToken, onTokenRefresh } from '@react-native-firebase/messaging';
import { requestNotifications } from 'react-native-permissions';
import { saveDeviceToken } from './api';

// Ask for permission (iOS, and Android 13+), then register this device's token.
// Returns a function that stops listening for token refreshes.
export async function registerForPush(userId: string): Promise<(() => void) | null> {
  const { status } = await requestNotifications(['alert', 'badge', 'sound']);
  if (status !== 'granted') return null; // respect the choice; offer a settings link later

  const messaging = getMessaging();
  await saveDeviceToken(await getToken(messaging), userId);

  // Tokens can change (app restore, reinstall, data cleared): keep the server in sync.
  return onTokenRefresh(messaging, token => saveDeviceToken(token, userId));
}
import { Platform } from 'react-native';

// Your backend. It stores tokens and sends messages with the Firebase Admin SDK.
const API_URL = 'https://api.example.com';

export async function saveDeviceToken(token: string, userId: string) {
  await fetch(`${API_URL}/devices`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ token, userId, platform: Platform.OS }),
  });
}

Ask at a moment the user understands, such as after they enable order updates, not on first launch. If they decline, the function returns without registering; offer a link to settings later.

Step 3: Handle Messages in the App

There are three situations. In the foreground, FCM does not show a system notification, so you show your own UI. When the user taps a notification while the app is in the background, onNotificationOpenedApp fires. When a tap launches the app from a quit state, getInitialNotification returns that message. This hook covers all three and routes using a screen value in the message’s data:

import { useEffect } from 'react';
import { Alert } from 'react-native';
import {
  getInitialNotification,
  getMessaging,
  onMessage,
  onNotificationOpenedApp,
  type RemoteMessage,
} from '@react-native-firebase/messaging';

type Open = (screen: string, params: Record<string, string>) => void;

// Route a tapped notification using its data payload, e.g. { screen: 'Order', orderId: '42' }.
function handleOpen(message: RemoteMessage | null, open: Open) {
  const data = (message?.data ?? {}) as Record<string, string>;
  if (data.screen) open(data.screen, data);
}

export function usePushHandlers(open: Open) {
  useEffect(() => {
    const messaging = getMessaging();

    // App was quit and opened by tapping a notification.
    getInitialNotification(messaging).then(m => handleOpen(m, open));

    // App was in the background and the user tapped a notification.
    const unsubOpened = onNotificationOpenedApp(messaging, m => handleOpen(m, open));

    // App is in the foreground: FCM does not show a system notification, so show something yourself.
    const unsubMessage = onMessage(messaging, m => {
      if (m.notification) Alert.alert(m.notification.title ?? '', m.notification.body ?? '');
    });

    return () => {
      unsubOpened();
      unsubMessage();
    };
  }, [open]);
}

For data messages while the app is in the background or quit, register a background handler at the top level, imported at the top of index.js before the app is registered. Keep it short and never update UI state there:

import { getMessaging, setBackgroundMessageHandler } from '@react-native-firebase/messaging';

// Import this file at the top of index.js, before AppRegistry.registerComponent.
// Runs for data messages while the app is in the background or quit. Keep it short
// and do not touch UI state here.
setBackgroundMessageHandler(getMessaging(), async message => {
  console.log('Background message', message.messageId);
});

For richer local notifications (custom channels, actions, images), pair FCM with a notification library such as Notifee or expo-notifications.

Step 4: Test the Client Logic

Native messaging cannot run in Jest, so the tests mock React Native Firebase and the permissions library, then check the logic that matters: register only when permission is granted, re-register on token refresh, and route taps from both cold start and background:

import { render } from '@testing-library/react-native';
import { registerForPush } from '../register';
import { usePushHandlers } from '../usePushHandlers';

const listeners: Record<string, (arg: any) => void> = {};

jest.mock('@react-native-firebase/messaging', () => ({
  getMessaging: () => ({}),
  getToken: jest.fn(async () => 'token-1'),
  onTokenRefresh: jest.fn((_m, cb) => { listeners.refresh = cb; return () => {}; }),
  getInitialNotification: jest.fn(async () => ({ data: { screen: 'Order', orderId: '42' } })),
  onNotificationOpenedApp: jest.fn((_m, cb) => { listeners.opened = cb; return () => {}; }),
  onMessage: jest.fn((_m, cb) => { listeners.message = cb; return () => {}; }),
}));

const mockRequest = jest.fn();
jest.mock('react-native-permissions', () => ({ requestNotifications: (...a: any[]) => mockRequest(...a) }));

beforeEach(() => {
  globalThis.fetch = jest.fn(async () => ({ ok: true })) as any;
});

test('registers the token when permission is granted, and on refresh', async () => {
  mockRequest.mockResolvedValue({ status: 'granted' });
  const stop = await registerForPush('user-1');
  expect(stop).toBeInstanceOf(Function);
  expect(JSON.parse((fetch as jest.Mock).mock.calls[0][1].body)).toMatchObject({ token: 'token-1', userId: 'user-1' });
  listeners.refresh('token-2');
  expect(JSON.parse((fetch as jest.Mock).mock.calls[1][1].body).token).toBe('token-2');
});

test('does nothing when permission is denied', async () => {
  mockRequest.mockResolvedValue({ status: 'denied' });
  expect(await registerForPush('user-1')).toBeNull();
  expect(fetch).not.toHaveBeenCalled();
});

test('routes opened notifications using their data payload', async () => {
  const open = jest.fn();
  function Probe() { usePushHandlers(open); return null; }
  await render(<Probe />);
  await new Promise(r => setTimeout(r, 0));
  expect(open).toHaveBeenCalledWith('Order', { screen: 'Order', orderId: '42' }); // cold start
  listeners.opened({ data: { screen: 'Chat', chatId: '7' } });
  expect(open).toHaveBeenLastCalledWith('Chat', { screen: 'Chat', chatId: '7' }); // from background
});
$ npx jest src/push
✓ registers the token when permission is granted, and on refresh
✓ does nothing when permission is denied
✓ routes opened notifications using their data payload

Step 5: Send From Your Server

The server uses the Firebase Admin SDK, which authenticates with a service account and calls the HTTP v1 API. sendEachForMulticast sends one message to a list of tokens and returns a result per token, so you can delete tokens that will never work again:

import type { Messaging, MulticastMessage } from 'firebase-admin/messaging';

export type Device = { token: string; userId: string; platform: string; updatedAt: Date };

// Minimal storage interface; back it with Firestore, Postgres, etc.
export interface DeviceStore {
  tokensForUser(userId: string): Promise<string[]>;
  remove(tokens: string[]): Promise<void>;
}

// FCM error codes that mean the token will never work again.
const DEAD_TOKEN_CODES = new Set([
  'messaging/registration-token-not-registered',
  'messaging/invalid-registration-token',
]);

export async function notifyUser(
  messaging: Pick<Messaging, 'sendEachForMulticast'>,
  store: DeviceStore,
  userId: string,
  title: string,
  body: string,
  data: Record<string, string> = {},
) {
  const tokens = await store.tokensForUser(userId);
  if (tokens.length === 0) return { sent: 0, removed: 0 };

  const message: MulticastMessage = {
    tokens,
    notification: { title, body },
    data, // values must be strings
    android: { priority: 'high', notification: { channelId: 'default' } },
    apns: { payload: { aps: { sound: 'default' } } },
  };

  const res = await messaging.sendEachForMulticast(message);
  const dead = res.responses
    .map((r, i) => (!r.success && r.error && DEAD_TOKEN_CODES.has(r.error.code) ? tokens[i] : null))
    .filter((t): t is string => t !== null);
  if (dead.length) await store.remove(dead);
  return { sent: res.successCount, removed: dead.length };
}
import { initializeApp } from 'firebase-admin/app';
import { getMessaging } from 'firebase-admin/messaging';
import { notifyUser, type DeviceStore } from './push.js';

// Uses Application Default Credentials (a service account) - no legacy server key.
initializeApp();

declare const store: DeviceStore; // your database-backed implementation

await notifyUser(getMessaging(), store, 'user-1', 'Order shipped', 'Your order #42 is on its way.', {
  screen: 'Order',
  orderId: '42',
});

On Google Cloud (Cloud Run, Cloud Functions) initializeApp() picks up credentials automatically; elsewhere, point GOOGLE_APPLICATION_CREDENTIALS at a service-account key file and keep it out of source control.

Token hygiene

Firebase’s token management guide recommends storing each token with a timestamp, refreshing it periodically, deleting tokens when FCM reports UNREGISTERED, and treating INVALID_ARGUMENT as a dead token only when you know the payload is valid. On Android, tokens inactive for 270 days expire. In the Admin SDK, UNREGISTERED surfaces as messaging/registration-token-not-registered; I confirmed that mapping in firebase-admin 14.5’s source. The code above deletes only on that code and on malformed tokens, and keeps tokens that fail for temporary reasons such as rate limits. The test:

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { notifyUser, type DeviceStore } from '../src/push.js';

function memoryStore(tokens: string[]): DeviceStore & { tokens: string[] } {
  return {
    tokens,
    async tokensForUser() { return this.tokens; },
    async remove(dead) { this.tokens = this.tokens.filter(t => !dead.includes(t)); },
  };
}

test('sends to every device and removes dead tokens only', async () => {
  const store = memoryStore(['good', 'unregistered', 'bad-format', 'throttled']);
  let sent: any;
  const messaging = {
    async sendEachForMulticast(msg: any) {
      sent = msg;
      const err = (code: string) => ({ success: false, error: { code } });
      return {
        successCount: 1,
        failureCount: 3,
        responses: [
          { success: true, messageId: 'm1' },
          err('messaging/registration-token-not-registered'),
          err('messaging/invalid-registration-token'),
          err('messaging/message-rate-exceeded'), // temporary: keep the token
        ],
      };
    },
  } as any;

  const result = await notifyUser(messaging, store, 'user-1', 'Hi', 'Body', { screen: 'Home' });
  assert.deepEqual(result, { sent: 1, removed: 2 });
  assert.deepEqual(store.tokens, ['good', 'throttled']);
  assert.deepEqual(sent.notification, { title: 'Hi', body: 'Body' });
  assert.deepEqual(sent.data, { screen: 'Home' });
});

test('does not call FCM when the user has no devices', async () => {
  const messaging = { sendEachForMulticast: () => assert.fail('should not send') } as any;
  assert.deepEqual(await notifyUser(messaging, memoryStore([]), 'u', 't', 'b'), { sent: 0, removed: 0 });
});
$ npx tsx --test test/push.test.ts
✔ sends to every device and removes dead tokens only
✔ does not call FCM when the user has no devices
ℹ pass 2

Testing on Real Devices

  • Use a physical iPhone for iOS; simulators on Apple Silicon do not get real APNs tokens.
  • Send a test message from Firebase console > Messaging using a device token, then try each state: foreground, background and quit.
  • On Android, create a notification channel (here default) and check the app’s notification settings if nothing appears.
  • iOS background handlers do not run when Background App Refresh is off or in Low Power Mode, so do not rely on them for critical work.

Conclusion

Push notifications with FCM come down to five things: ask for permission at the right time, send the device token to your server and keep it fresh, handle foreground, background and quit states in the app, send through the HTTP v1 API with the Admin SDK, and delete dead tokens. Test the logic with mocks, then test delivery on real devices. My original 2023 sample app is still on GitHub, but it uses the older APIs described above.

FAQs

Do I still need the FCM server key?

No. The legacy FCM APIs that used server keys were deprecated in 2023 and shut down starting July 2024. Send with the HTTP v1 API, most easily through the Firebase Admin SDK with a service account.

Why are notifications not showing on Android 13 or newer?

Android 13 (API 33) added the POST_NOTIFICATIONS runtime permission. Request it, for example with react-native-permissions, before expecting notifications to appear.

Why do I not see a notification when the app is open?

FCM does not display notification messages while the app is in the foreground. Handle them with onMessage and show your own in-app UI or a local notification.

Can I test push notifications on the iOS simulator?

Not reliably with FCM. Apple Silicon simulators do not provide a real APNs token, so test push delivery on a physical device.

When should I delete an FCM token?

When sending fails with an unregistered or invalid-token error, when the user signs out, and when the token has not been refreshed for a long time. On Android, tokens inactive for 270 days expire.

Is Firebase Cloud Messaging free?

Yes. FCM itself has no per-message charge. You pay only for the server infrastructure you use to send messages.

Snehasish Konger
Developed @scientyficworld.org | Technical writer @Nected | Content Developer
Connect with Snehasish Konger

On This page

Take a Pause with Intervals

A Sunday letter on building, writing, and thinking deeper as a developer — short, honest, and worth your time.

Snehasish Konger profile photo

"Hey there — I'm Snehasish. Hope this post saved you some head-scratching time! I've spent years turning technical chaos into clarity, and I'm here to be your guide through the maze of modern tech. Stick around for more lightbulb moments — we're just getting started."

Related Posts