Home / Articles

React Native Notifications: Permissions, Channels and the FCM Lifecycle

Learn how permissions, Android channels, FCM tokens and foreground, background and quit-state handlers fit together in a React Native notification system with Notifee.

Publisher: REACTAPP.TOP19 min read4332 words

This article is published in English.

Sources: React Native Notifications & Permissions: A Complete Guide for Android and iOS

To a user, a notification is one line of text such as "Your order has been shipped". To the app that sent it, that line depends on runtime permissions, Android notification channels, device tokens, Firebase Cloud Messaging (FCM), background handlers and a set of platform differences between Android and iOS. A mistake in any layer yields notifications that never arrive or open the wrong screen. This guide walks through each layer with Notifee and React Native Firebase so you can build a notification system that behaves predictably in every app state.

Local notifications and push notifications

Local notifications

A local notification is created on the device by the app itself, with no server involved. A meeting reminder is a typical case:

Reminder
Your meeting starts in 10 minutes.

The app can show or schedule it offline. Notifee provides one React Native API over the native notification systems of both platforms.

Push notifications

A push notification starts on a server. Your backend hands the message to FCM, FCM delivers it to the device, and the React Native app reacts:

Your Backend
     │
     ▼
Firebase Cloud Messaging
     │
     ▼
Android / iOS Device
     │
     ▼
React Native Application

FCM distinguishes between notification messages (which the system can display for you) and data messages (arbitrary key-value payloads for your code), and it can address a single device or a group of devices through topics.

Permission is part of your app state

Installing an app no longer implies that it may show notifications. Since Android 13, POST_NOTIFICATIONS is a runtime permission, and apps targeting Android 13 or later must ask for it before displaying anything. iOS has always required explicit user authorization. Treat permission as app state that can change, not a one-time setup step.

A sensible flow checks status, explains the benefit, then asks; either answer lets the app continue:

Application starts
       │
       ▼
Check notification permission
       │
       ├── Granted ───────► Continue
       │
       └── Not granted
                │
                ▼
        Explain the benefit
                │
                ▼
        Request permission
                │
          ┌─────┴─────┐
          ▼           ▼
       Granted      Denied
          │           │
          ▼           ▼
     Notifications   Continue

Installing the libraries

Notifee handles displaying notifications:

npm install @notifee/react-native

React Native Firebase provides the core app module and the messaging module for push:

npm install @react-native-firebase/app
npm install @react-native-firebase/messaging

On iOS, install the native CocoaPods dependencies afterwards:

cd ios
pod install
cd ..

Native configuration varies by React Native version and project setup, and iOS push also needs APNs credentials in Firebase and the push capability in Xcode. Follow each library's current docs.

Displaying a first native notification

The goal of the first example is a notification with this title and body:

New Message
You have received a new message.

Start by importing Notifee together with the importance enum used for Android channels:

import notifee, {
  AndroidImportance,
} from '@notifee/react-native';

The function below does three things in order: it requests permission, creates (or reuses) an Android channel called default, and displays the notification on that channel. The pressAction with id default tells Android to open the app when the notification is tapped; without it, a tap does nothing.

export async function displayNotification() {
  // Request permission
  await notifee.requestPermission();  // Create Android notification channel
  const channelId = await notifee.createChannel({
    id: 'default',
    name: 'Default Notifications',
    importance: AndroidImportance.DEFAULT,
  });  // Display notification
  await notifee.displayNotification({
    title: 'New Message',
    body: 'You have received a new message.',    android: {
      channelId,
      pressAction: {
        id: 'default',
      },
    },
  });
}

Calling createChannel repeatedly is safe; an existing channel just returns its id. Channels are mandatory on Android 8.0 (API 26) and later. Trigger the function from a button:

<Button
  title="Show Notification"
  onPress={displayNotification}
/>

Requesting the Android 13 permission

The Android permission has this full name:

android.permission.POST_NOTIFICATIONS

React Native exposes it as a constant on PermissionsAndroid:

PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS

The permission must also be declared with uses-permission in AndroidManifest.xml, or the request is silently denied. Begin with the imports:

import {
  PermissionsAndroid,
  Platform,
} from 'react-native';

The helper returns true immediately on iOS and on Android versions below 13 (API level 33), where notifications are allowed by default. Otherwise it shows the system dialog and reports whether the result was GRANTED:

export async function requestAndroidNotificationPermission() {
  if (Platform.OS !== 'android') {
    return true;
  }  if (Platform.Version < 33) {
    return true;
  }  const result = await PermissionsAndroid.request(
    PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS,
  );  return result === PermissionsAndroid.RESULTS.GRANTED;
}

Call it from a screen:

const granted =
  await requestAndroidNotificationPermission();

and branch on the result:

if (granted) {
  console.log('Notifications allowed');
} else {
  console.log('Notifications denied');
}

Android can return three meaningful results:

GRANTED
DENIED
NEVER_ASK_AGAIN

NEVER_ASK_AGAIN deserves special attention. After repeated refusals the system stops showing the dialog, and only system settings can change the answer.

Asking at the right moment

Launching the system prompt on the very first screen is a common and costly mistake. A new user who immediately sees

"Allow MyApp to send notifications?"

has no context and will likely refuse. Explain first, for instance on a welcome screen:

Welcome to MyApp

followed by a card that lists what notifications are for, with a button:

Enable notifications to receive:
✓ New messages
✓ Order updates
✓ Important alerts[Enable Notifications]

Only that tap triggers the OS dialog, so the request answers a user choice instead of interrupting.

iOS authorization with Notifee

On iOS the user must authorize delivery explicitly. Notifee reports the outcome through AuthorizationStatus:

import notifee, {
  AuthorizationStatus,
} from '@notifee/react-native';

The helper below treats two statuses as success. AUTHORIZED is full permission. PROVISIONAL is iOS's quiet mode, in which notifications are delivered directly to Notification Center without interrupting the user, and the user can later keep or turn them off. Everything else is treated as a refusal:

export async function requestIOSNotificationPermission() {
  const settings = await notifee.requestPermission();  if (
    settings.authorizationStatus ===
    AuthorizationStatus.AUTHORIZED
  ) {
    console.log('Notifications authorized');
    return true;
  }  if (
    settings.authorizationStatus ===
    AuthorizationStatus.PROVISIONAL
  ) {
    console.log('Notifications provisionally authorized');
    return true;
  }  console.log('Notifications denied');  return false;
}

The iOS request can include a range of options:

Alerts
Sounds
Badges
CarPlay
Announcements
Provisional notifications

Request only what you use; provisional authorization, for example, skips the prompt entirely.

One permission function for the whole app

Rather than scattering checks across screens, use one service module:

src/
 ├── services/
 │    └── notificationService.ts
 ├── screens/
 ├── components/
 └── App.tsx

The service file:

// notificationService.ts

combines both platform paths. On Android it applies the version check and the runtime request; on iOS it uses Notifee and accepts both authorized and provisional status:

import {
  Platform,
  PermissionsAndroid,
} from 'react-native';import notifee, {
  AuthorizationStatus,
} from '@notifee/react-native';export async function requestNotificationPermission() {
  if (Platform.OS === 'android') {
    if (Platform.Version < 33) {
      return true;
    }    const result = await PermissionsAndroid.request(
      PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS,
    );    return result === PermissionsAndroid.RESULTS.GRANTED;
  }  const settings = await notifee.requestPermission();  return (
    settings.authorizationStatus ===
      AuthorizationStatus.AUTHORIZED ||
    settings.authorizationStatus ===
      AuthorizationStatus.PROVISIONAL
  );
}

Screens then call one function:

const enabled = await requestNotificationPermission();

and act on the answer:

if (enabled) {
  console.log('Ready for notifications');
}

Recent Notifee versions can also request POST_NOTIFICATIONS on Android 13 through requestPermission(); check the current Notifee docs if you prefer a single call for both platforms.

Android notification channels

Android 8.0 introduced channels so that users can control categories of notifications independently. An app might expose several:

MyApp
│
├── Messages
├── Orders
├── Promotions
└── Security

Each channel has its own importance and sound. Messages usually deserve high importance so they pop up:

const messageChannel = await notifee.createChannel({
  id: 'messages',
  name: 'Messages',
  importance: AndroidImportance.HIGH,
});

while order updates can use the default level:

const orderChannel = await notifee.createChannel({
  id: 'orders',
  name: 'Orders',
  importance: AndroidImportance.DEFAULT,
});

A notification then names the channel it belongs to. The call is shown in two parts, the title and body first:

await notifee.displayNotification({
  title: 'New Message',
  body: 'You received a new message.',

and then the Android options referencing the channel id returned by createChannel:

  android: {
    channelId: messageChannel,
  },
});

Channels are user-facing, not just a developer setting. Users can silence or block a channel in system settings, and after a channel has been created, the app can no longer change its importance programmatically; the user owns that choice. Choose carefully, or use a new channel id later.

A structure that works well in production separates channels by purpose:

notifications
│
├── messages
├── orders
├── payments
├── security
└── marketing

A separate marketing channel lets users mute promotions without losing security alerts.

How FCM fits in

For push, FCM sits between your backend and every installed app:

┌─────────────────┐
             │   Your Backend  │
             └────────┬────────┘
                      │
                      ▼
             ┌─────────────────┐
             │      FCM        │
             └────────┬────────┘
                      │
              ┌───────┴───────┐
              ▼               ▼
        ┌──────────┐     ┌──────────┐
        │ Android  │     │   iOS    │
        └──────────┘     └──────────┘

Obtaining the device token

Each installation of the app can receive an FCM registration token, and your backend needs that token to address a specific device. Import the messaging module:

import messaging from '@react-native-firebase/messaging';

and ask for the token:

export async function getFCMToken() {
  const token = await messaging().getToken();  console.log('FCM Token:', token);  return token;
}

In a real app the token is sent to your server right after it is obtained:

const token = await messaging().getToken();
await fetch('https://api.example.com/device-token', {
  method: 'POST',  headers: {
    'Content-Type': 'application/json',
  },  body: JSON.stringify({
    token,
  }),
});

The backend then stores the token against the signed-in user and the device:

User
  │
  ├── userId
  ├── deviceId
  └── fcmToken

Keeping tokens current

Tokens change, for example after reinstalling or clearing app data. Listen for refresh events:

import messaging from '@react-native-firebase/messaging';

The listener forwards each new token and the function returns an unsubscribe callback:

export function subscribeToTokenRefresh() {
  return messaging().onTokenRefresh(async token => {
    console.log('New FCM token:', token);    await sendTokenToBackend(token);
  });
}

A minimal upload helper, again shown in two parts, performs a JSON POST:

async function sendTokenToBackend(token: string) {
  await fetch('https://api.example.com/device-token', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },    body: JSON.stringify({
      token,
    }),
  });
}

Firebase's own guidance is to handle token changes and push the new value to your application server whenever it changes. On the server side, also remove tokens that FCM reports as invalid, so stale devices do not accumulate.

Foreground messages are not displayed automatically

The most confusing case is a push arriving while the app is open:

User
  │
  ▼
React Native App
  │
  ▼
FCM message

Listen for foreground messages:

import messaging from '@react-native-firebase/messaging';
const unsubscribe = messaging().onMessage(
  async remoteMessage => {
    console.log('Foreground message:', remoteMessage);
  },
);

However, receiving a message and showing a system notification are separate steps. While the app is in the foreground, FCM hands the message to your code and does not display anything by itself. To show one, pass the content to Notifee:

import notifee from '@notifee/react-native';

The handler below ensures the channel exists, then displays the notification, falling back to default text when the message has no notification block (for example, a data-only message):

messaging().onMessage(async remoteMessage => {
  const channelId = await notifee.createChannel({
    id: 'messages',
    name: 'Messages',
  });  await notifee.displayNotification({
    title:
      remoteMessage.notification?.title ??
      'New Message',    body:
      remoteMessage.notification?.body ??
      'You have a new message.',    android: {
      channelId,
      pressAction: {
        id: 'default',
      },
    },
  });
});

You control presentation, including skipping it when the relevant screen is already visible.

Background and quit-state messages

When the app is in the background, behaviour changes. On Android, FCM notification messages go straight to the system tray without invoking your foreground callback, and messages that combine notification and data payloads follow their own background rules. On iOS, data-only background messages are best-effort and may be delayed or throttled by the system.

React Native Firebase provides a dedicated handler for background messages:

import messaging from '@react-native-firebase/messaging';
messaging().setBackgroundMessageHandler(
  async remoteMessage => {
    console.log(
      'Background message:',
      remoteMessage,
    );
  },
);

A frequent mistake is registering this handler inside a React component, as in the following fragment:

function App() {
  messaging().setBackgroundMessageHandler(...);
  return <Navigation />;
}

A background wake-up may mount no component, so the handler would never exist. Register it at the entry point:

// index.js
import messaging from '@react-native-firebase/messaging';messaging().setBackgroundMessageHandler(
  async remoteMessage => {
    console.log(
      'Background message:',
      remoteMessage,
    );
  },
);

and only then register the root component:

AppRegistry.registerComponent(
  appName,
  () => App,
);

Reacting to notification taps

A tap should usually take the user somewhere specific:

Notification
     │
     ▼
User taps
     │
     ▼
Application opens
     │
     ▼
Order Details

Suppose the message carries an order id in its data:

{
  "orderId": "12345"
}

For apps resumed from the background, onNotificationOpenedApp fires when the user taps a notification. The callback reads the id from remoteMessage.data and can navigate from there:

messaging().onNotificationOpenedApp(
  remoteMessage => {
    const orderId =
      remoteMessage.data?.orderId;
    if (orderId) {
      // Navigate to order details
      console.log(
        'Open order:',
        orderId,
      );
    }
  },
);

When the app was fully closed

If the app was terminated, the sequence looks like this:

App completely closed
        │
        ▼
User receives notification
        │
        ▼
User taps notification
        │
        ▼
App launches

In that case onNotificationOpenedApp does not fire. Instead, ask for the notification that launched the app once during startup:

const remoteMessage =
  await messaging().getInitialNotification();
if (remoteMessage) {
  const orderId =
    remoteMessage.data?.orderId;  console.log(
    'App opened from notification:',
    orderId,
  );
}

Both methods only cover notifications that FCM displayed. Taps on notifications that you displayed yourself with Notifee, such as the foreground ones above, arrive through Notifee's own event APIs (onForegroundEvent, onBackgroundEvent and its getInitialNotification). Handling only one family is a common source of "the tap does nothing" bugs.

Payloads designed for navigation

A notification that only says

New Order

tells the app nothing about where the user should land. Include structured data next to the visible text:

{
  "notification": {
    "title": "New Order",
    "body": "Order #12345 has been confirmed"
  },
  "data": {
    "type": "order",
    "orderId": "12345"
  }
}

A router function can then map the type to a screen:

function handleNotification(data: any) {
  if (data.type === 'order') {
    navigation.navigate(
      'OrderDetails',
      {
        orderId: data.orderId,
      },
    );
  }
}

Because notification handlers often run outside components, navigation in such helpers is typically a navigation ref created at the app root. For a full treatment of routing taps through deep links, see routing push notification taps through deep links in React Native.

Detecting permission changes after install

Permission is not stable over time. A user can allow notifications on Monday:

Monday:
Notifications = ON

and switch them off from system settings on Wednesday:

Wednesday:
User opens Android SettingsNotifications = OFF

The app should notice. Notifee reads the current settings without prompting:

import notifee, {
  AuthorizationStatus,
} from '@notifee/react-native';
export async function checkNotificationPermission() {
  const settings =
    await notifee.getNotificationSettings();  if (
    settings.authorizationStatus ===
    AuthorizationStatus.AUTHORIZED
  ) {
    return true;
  }  return false;
}

Note that this check returns false for provisional authorization; include PROVISIONAL if your app treats it as enabled. Re-run it when the app returns to the foreground.

When the user says no

Do not repeat the prompt. Show a clear state instead, such as a banner:

Notifications are disabled

with an action that leads to settings:

Enable notifications from Settings
to receive important updates.[Open Settings]

Notifee offers APIs that open the app's notification settings directly. The principles behind all of this fit on one card:

Don't force
Don't spam
Explain the value
Give the user control

Centralizing notification setup

In production code, keep everything notification-related in a few well-defined modules:

src/
│
├── services/
│   ├── notificationService.ts
│   └── firebaseMessaging.ts
│
├── navigation/
│   └── notificationNavigation.ts
│
├── screens/
│
└── App.tsx

The main service:

// notificationService.ts

exposes an initializer that requests permission, stops if the user refused, and otherwise creates the default channel:

import notifee, {
  AndroidImportance,
  AuthorizationStatus,
} from '@notifee/react-native';export async function initializeNotifications() {
  const settings =
    await notifee.requestPermission();  const authorized =
    settings.authorizationStatus ===
      AuthorizationStatus.AUTHORIZED ||
    settings.authorizationStatus ===
      AuthorizationStatus.PROVISIONAL;  if (!authorized) {
    return false;
  }  await notifee.createChannel({
    id: 'default',
    name: 'Default Notifications',
    importance: AndroidImportance.DEFAULT,
  });  return true;
}

Calling it from a component is straightforward:

useEffect(() => {
  initializeNotifications();
}, []);

Beware: on the first screen this prompts immediately, the timing problem described earlier. Call it from the opt-in button instead.

The backend side

A typical data model keeps users and their devices in separate tables, since one user can have several phones or tablets:

users
│
├── id
├── name
└── email
user_devices
│
├── id
├── user_id
├── device_id
├── fcm_token
├── platform
├── created_at
└── updated_at

A device registration record might look like this:

{
  "userId": 101,
  "deviceId": "device-abc",
  "fcmToken": "xxxxx",
  "platform": "android"
}

When a business event occurs, the backend looks up the user's tokens and sends through FCM:

Order Created
      │
      ▼
Backend
      │
      ▼
Find user's FCM token
      │
      ▼
Firebase Cloud Messaging
      │
      ▼
User's phone

FCM expects messages to be sent from a trusted environment, such as your application server or Cloud Functions, never from the client, because sending needs server credentials.

Payloads that scale

Design every payload so the app understands the event, not just the text. A payment confirmation could carry:

{
  "notification": {
    "title": "Payment Successful",
    "body": "Your payment of ₹999 was successful."
  },
  "data": {
    "type": "payment",
    "paymentId": "PAY12345",
    "screen": "PaymentDetails"
  }
}

A single switch on data.type then handles every kind of notification:

switch (data.type) {
  case 'payment':
    navigation.navigate(
      'PaymentDetails',
      {
        paymentId: data.paymentId,
      },
    );
    break;
  case 'order':
    navigation.navigate(
      'OrderDetails',
      {
        orderId: data.orderId,
      },
    );
    break;
}

A new notification type is one new case. FCM data values are strings, so parse numbers explicitly.

The full architecture

Putting the pieces together, a production setup looks like this:

┌──────────────────┐
                    │    Backend API   │
                    └────────┬─────────┘
                             │
                             ▼
                    ┌──────────────────┐
                    │ Firebase / FCM   │
                    └────────┬─────────┘
                             │
                ┌────────────┴────────────┐
                │                         │
                ▼                         ▼
        ┌──────────────┐          ┌──────────────┐
        │    Android   │          │     iOS      │
        └──────┬───────┘          └──────┬───────┘
               │                         │
               ▼                         ▼
        Notification                 APNs / FCM
          Channel                        │
               │                         │
               └────────────┬────────────┘
                            ▼
                    React Native App
                            │
                            ▼
                    Navigation / UI

On iOS, FCM does not deliver messages itself; it relays them through Apple Push Notification service (APNs), which is why APNs credentials must be configured.

Common mistakes

Prompting on first launch

This pattern on the first screen, without any explanation, is the most common mistake:

useEffect(() => {
  requestNotificationPermission();
}, []);

Explain the value first and request permission in response to a user action.

Forgetting channels

Without a channel, notifications will not appear on Android 8.0 and later. Create one before displaying:

await notifee.createChannel({
  id: 'messages',
  name: 'Messages',
});

Expecting foreground pushes to appear

Listening with

messaging().onMessage(...)

does not mean a visible system notification will appear in every case. For consistent foreground presentation, display it yourself with Notifee.

Ignoring token changes

A token saved once silently stops working. Use

messaging().onTokenRefresh(...)

and send each new token to the backend.

Registering background handlers in components

The background handler belongs at the entry point, outside the React tree, as shown earlier.

Ignoring later permission changes

Users can revoke permission or block a single channel at any time. Your checks should account for every one of these states:

Granted
Denied
Changed externally
Channel blocked
App notifications disabled

Leaking sensitive data

Notification text is visible on the lock screen and to anyone near the device. Rather than

Your bank account ending in 1234 received ₹50,000.

send something neutral:

New account activity
Tap to view details.

and show the details only after the user has authenticated inside the app.

Testing checklist

Behaviour varies by OS version, app state and device, so test systematically. On Android:

☐ Android 12
☐ Android 13+
☐ Permission granted
☐ Permission denied
☐ Permission disabled from Settings
☐ Channel enabled
☐ Channel disabled
☐ App foreground
☐ App background
☐ App killed
☐ Notification tapped
☐ Multiple notifications

On iOS:

☐ Permission granted
☐ Permission denied
☐ Permission changed from Settings
☐ App foreground
☐ App background
☐ App terminated
☐ Notification tapped
☐ Badge
☐ Sound
☐ Deep linking

And for the FCM integration end to end:

☐ Token generated
☐ Token stored on backend
☐ Token refresh handled
☐ Foreground message received
☐ Background message received
☐ Notification click handled
☐ Initial notification handled

The runtime flow at a glance

The complete sequence, from app start through delivery to navigation, looks like this:

APP START
                       │
                       ▼
              Check permissions
                       │
              ┌────────┴────────┐
              │                 │
           Allowed            Denied
              │                 │
              ▼                 ▼
       Initialize FCM      Show explanation
              │                 │
              ▼                 ▼
       Get FCM token       Request permission
              │
              ▼
       Send token to API
              │
              ▼
          App Running
              │
       ┌──────┴──────┐
       │             │
       ▼             ▼
   Foreground     Background
       │             │
       ▼             ▼
    onMessage      FCM/APNs
       │             │
       ▼             ▼
    Notifee       System UI
       │             │
       └──────┬──────┘
              ▼
        User taps
              │
              ▼
       Read notification data
              │
              ▼
          Navigation

Local versus push, and using both together

The two notification types differ in a few predictable ways:

  • Trigger: local notifications come from the app or device; push notifications come from your backend.
  • Network: local notifications usually work offline; push requires connectivity.
  • FCM: not needed for local notifications; usually required for push.
  • Scheduled reminders: a natural fit for local; not the typical use of push.
  • Server events, order updates, chat messages: push is the normal choice; local can only cover them in limited ways.
  • Libraries: Notifee for local; FCM plus a display library for push.

They also combine well. A common setup lets FCM deliver the message, your code process it, and Notifee present it:

FCM receives message
        ↓
React Native processes message
        ↓
Notifee displays native notification

What users should see at each step

For manual verification, know what to expect. On Android 13+, a system dialog appears:

Allow MyApp to send you notifications?

In the app's system notification settings, the channels you created appear as separate toggles:

MyApp
 ├── Messages
 ├── Orders
 └── Promotions

On iOS, the system authorization prompt appears, and delivered notifications show up in Notification Center. For push, the Firebase console can send a test message directly to a device token, which is the quickest way to separate backend bugs from client bugs:

Firebase Console
      ↓
Cloud Messaging
      ↓
Test Message
      ↓
FCM Token

Finally, tapping an order notification:

Notification
     ↓
Order #12345

should land on the matching screen:

Order Details Screen

Production best practices

Time the permission request so users understand why notifications help, rather than asking because the app started. Keep the logic centralized in one service instead of duplicating it across screens:

notificationService.ts

Separate notification types into channels or categories such as:

messages
orders
payments
security
marketing

Design payloads for navigation, including fields like these where appropriate:

type
id
screen

Beyond that:

  • Tie tokens to authenticated users and devices, store them securely, and handle refreshes.
  • Keep sensitive information out of notification text, since it is visible outside the app.
  • Test on real devices, because emulators and simulators can behave differently.

Wrapping up

A working notification system is much more than a single call like

showNotification();

It is the combination of all these concerns:

Permissions
     +
Notification Channels
     +
FCM Tokens
     +
Foreground Handling
     +
Background Handling
     +
Notification Clicks
     +
Deep Linking
     +
Backend Integration

The key is a clear division of responsibility. FCM delivers messages. The operating system owns permissions and native presentation. Notifee gives React Native control over local presentation. Your backend decides when to send what, and to whom. Seen as one system, it looks like this:

┌─────────────────┐
              │   Backend API   │
              └────────┬────────┘
                       │
                       ▼
              ┌─────────────────┐
              │      FCM        │
              └────────┬────────┘
                       │
              ┌────────┴────────┐
              ▼                 ▼
        ┌───────────┐     ┌───────────┐
        │  Android  │     │    iOS    │
        │  Channel  │     │   APNs    │
        └─────┬─────┘     └─────┬─────┘
              │                 │
              └────────┬────────┘
                       ▼
                React Native
                       │
                       ▼
                 Notifee
                       │
                       ▼
                Native UI

With those boundaries in place, notifications behave predictably across Android and iOS and across foreground, background and terminated states.

Quick reference

Request the Android permission:

await PermissionsAndroid.request(
  PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS,
);

Request iOS permission:

await notifee.requestPermission();

Create an Android channel:

await notifee.createChannel({
  id: 'messages',
  name: 'Messages',
});

Display a notification:

await notifee.displayNotification({
  title: 'New Message',
  body: 'You have a new message.',
  android: {
    channelId: 'messages',
  },
});

Listen for foreground FCM messages:

messaging().onMessage(async message => {
  console.log(message);
});

Listen for token changes:

messaging().onTokenRefresh(token => {
  console.log(token);
});

Handle notification opens from the background:

messaging().onNotificationOpenedApp(message => {
  console.log(message.data);
});