Notify Kit FCM Mode
Hitting upstream Notifee #1133 ("onBackgroundEvent not triggered on iOS for remote notifications")? FCM Mode resolves it. The client-side
onBackgroundEventhandler only fires for remote pushes on iOS when the APNs payload carries anotifee_optionsblob that the Notification Service Extension can enrich intouserInfo[__notifee_notification]before delivery. Shipping FCM payloads via the server SDK and wiring the NSE through the Expo config plugin or the bare React Nativeinit-nseCLI is the end-to-end fix. Expo Go is not supported.
A delivery pattern for apps that want react-native-notify-kit as the sole display layer for FCM push notifications on both Android and iOS.
FCM Mode solves two problems at once: the Android notification-payload duplicate (the system tray draws the push, and then your client draws it again via displayNotification) and the iOS data-only payload drop rate (APNs throttles silent pushes aggressively — ~30–60% loss is typical on real devices). It uses a different FCM payload shape per platform but the same developer API, so the asymmetry is invisible to your app code.
The problem
FCM has two delivery modes, and neither is a good default for apps that use Notify Kit for display:
| Payload shape | Android behavior | iOS behavior |
|---|---|---|
notification (alert) | OS auto-displays via the FCM SDK. If you also call displayNotification in setBackgroundMessageHandler, you get two tray entries. Custom data is routed to the tap PendingIntent only, so getDisplayedNotifications() can't see it (tracked at firebase-android-sdk#2639). | APNs delivers reliably. If you want Notify Kit to rewrite the notification (attachments, categories, custom sound), you need a Notification Service Extension. |
data only | OS wakes your app and you call displayNotification yourself — clean, but iOS treats these as silent pushes and throttles / drops them aggressively. Real-device loss rates of 30–60% are typical; worse under Low Power Mode and with the app force-quit. | Same delivery throttling as above. Unusable for user-facing pushes. |
The solution
FCM Mode picks the right payload per platform and hides the asymmetry behind a single developer API:
- Android uses a data-only payload, so the FCM SDK never auto-displays. Your app receives the message in
setBackgroundMessageHandler/onMessageand callsnotifee.handleFcmMessage(remoteMessage)— a one-liner that parses the embeddednotifee_optionsblob and renders the notification with full control over channel, style, actions, etc. - iOS uses an alert payload (
aps.alert) withmutable-content: 1and anotifee_optionsblob inapns.payload. A Notification Service Extension reads the blob, reshapes the notification (attachments, category, thread-id, interruption level, sound), and the OS displays the rewritten content. APNs reliability (~99% on APNs priority 10) instead of the silent-push throttle. - The server SDK (
react-native-notify-kit/server) generates both payload shapes from onebuildNotifyKitPayload(input)call, so your backend doesn't care about the split. - The Expo config plugin generates and wires the iOS NSE during Expo prebuild and can configure Android foreground service manifest requirements when explicitly opted in. For bare React Native, the CLI (
npx react-native-notify-kit init-nse) scaffolds the same NSE target, patches the Podfile, and wires the.pbxproj.
If you're already using the "data-only + displayNotification from headless task" pattern documented in the main README, FCM Mode is a superset: it replaces the iOS half with an alert + NSE path that doesn't drop pushes. See the migration guide below.
When to use FCM Mode
Use it when:
- You send push notifications from a Node.js backend (or Firebase Cloud Functions) and want one library to own display on both platforms.
- You need consistent behavior across platforms (styles, channels, actions, attachments, tap handling) without maintaining platform branches server-side.
- You want iOS APNs delivery reliability without giving up client-side control over presentation.
Stick with a simpler pattern when:
- You only ship Android, or only ship iOS — the asymmetry cost disappears.
- You're happy with iOS data-only drops — many marketing / engagement notifications are fine at 60% delivery.
- You need Expo Go. FCM Mode and foreground service support require native modules or native targets, so use Expo CNG / prebuild development builds instead.
Architecture
┌──────────────────┐
│ Your backend │
│ (Node.js / CFns) │
└────────┬─────────┘
│ buildNotifyKitPayload({ token, notification, options })
▼
┌──────────────────────────────────────┐
│ react-native-notify-kit/server │
│ - Android: data-only payload │
│ - iOS: aps.alert + mutable=1 │
│ - notifee_options blob (identical) │
└────────┬─────────────────────────────┘
│ admin.messaging().send(message)
▼
┌──────────────────┐ ┌──────────────────┐
│ FCM service │ ──► Android ──► │ Device (app) │
│ (HTTP v1) │ │ setBackgroundMsg │
│ │ │ Handler → ... │
│ │ │ handleFcmMessage │
│ │ │ → displayNotif. │
│ │ └──────────────────┘
│ │
│ │ ──► iOS (APNs) ► ┌──────────────────┐
│ │ │ NSE (NotifyKit- │
│ │ │ NSE.appex) │
│ │ │ - reads blob │
│ │ │ - attachments │
│ │ │ - OS draws it │
│ │ └──────────────────┘
└──────────────────┘
The notifee_options blob is byte-identical on both platforms — on Android it rides in data.notifee_options, on iOS in apns.payload.notifee_options. Title and body are duplicated into aps.alert on iOS so the OS can display the initial banner before the NSE finishes. Each platform ignores the fields the other platform needs.
1. Install
yarn add react-native-notify-kit @react-native-firebase/app @react-native-firebase/messaging
The CLI and the server SDK ship with the main package — no extra installs.
2. Server: build and send the payload
// server/sendNotification.ts
import { buildNotifyKitPayload } from 'react-native-notify-kit/server';
import * as admin from 'firebase-admin';
admin.initializeApp();
export async function sendOrderUpdate(deviceToken: string, orderId: string) {
const message = buildNotifyKitPayload({
token: deviceToken,
notification: {
id: `order-${orderId}`,
title: 'Your order is on the way',
body: 'Tap to see live tracking.',
data: { orderId, screen: 'tracking' },
android: {
channelId: 'orders',
smallIcon: 'ic_notification',
color: '#4CAF50',
pressAction: { id: 'open-order', launchActivity: 'default' },
},
ios: {
sound: 'default',
categoryId: 'ORDER_UPDATE',
interruptionLevel: 'timeSensitive',
attachments: [{ url: 'https://cdn.example.com/orders/42.png' }],
},
},
options: {
androidPriority: 'high',
iosBadgeCount: 1,
ttl: 3600,
},
});
await admin.messaging().send(message);
}
3. Android client: wire handleFcmMessage
In your app's index.js (before AppRegistry.registerComponent):
// index.js
import { AppRegistry } from 'react-native';
import messaging from '@react-native-firebase/messaging';
import notifee, { AndroidImportance } from 'react-native-notify-kit';
import App from './App';
// Optional: configure defaults once at startup
notifee.setFcmConfig({
defaultChannelId: 'default',
defaultPressAction: { id: 'default', launchActivity: 'default' },
});
// Create the channel your payloads reference
notifee.createChannel({ id: 'orders', name: 'Orders', importance: AndroidImportance.HIGH });
notifee.createChannel({ id: 'default', name: 'Default', importance: AndroidImportance.HIGH });
// Background + killed state
messaging().setBackgroundMessageHandler(async remoteMessage => {
await notifee.handleFcmMessage(remoteMessage);
});
AppRegistry.registerComponent('MyApp', () => App);
And in a component for foreground delivery:
// App.tsx
import { useEffect } from 'react';
import messaging from '@react-native-firebase/messaging';
import notifee from 'react-native-notify-kit';
export default function App() {
useEffect(() => {
const unsubscribe = messaging().onMessage(async remoteMessage => {
await notifee.handleFcmMessage(remoteMessage);
});
return unsubscribe;
}, []);
// ... your UI
}
That's it for Android. On iOS the same handleFcmMessage call is a no-op in background/killed (the NSE has already displayed the notification); in foreground it displays the in-app banner as usual.
4. iOS client: create the NSE
For Expo CNG / development builds, add the config plugin and run Expo prebuild. Do not run this in Expo Go.
export default {
expo: {
name: 'MyApp',
slug: 'my-app',
ios: {
bundleIdentifier: 'com.example.myapp',
},
plugins: [
[
'react-native-notify-kit',
{
ios: {
notificationServiceExtension: true,
},
},
],
],
},
};
For bare React Native, run:
npx react-native-notify-kit init-nse
cd ios && pod install
Both paths create a NotifyKitNSE target, add the RNNotifeeCore dependency, and embed the .appex in the host app. Full detail in iOS NSE setup.
5. Verify
Send a test payload to a real device. The notification should:
- Display once on both platforms (no duplicates).
- Show any attachments, category actions, thread-id grouping, and custom sounds server-side.
- Route tap handling through your registered handlers after you configure and validate your app's press-action flow.
Keep reading for the full API surface and error cases.
Expo CNG / development builds
Expo CNG / prebuild / development builds are supported. Expo Go is not supported because NotifyKit requires native modules, and iOS FCM Mode requires a native Notification Service Extension target embedded in the app.
iOS and Android have different responsibilities:
- iOS FCM Mode uses the NotifyKit config plugin to generate and wire the Notification Service Extension during prebuild.
- Android FCM Mode remains data-only. RNFirebase receives the message and your app calls
notifee.handleFcmMessage(remoteMessage). - Android foreground service manifest configuration is available through the NotifyKit config plugin, but it is explicit opt-in.
iOS Expo setup
Boolean true enables the default NSE target during Expo prebuild:
export default {
expo: {
name: 'MyApp',
slug: 'my-app',
ios: {
bundleIdentifier: 'com.example.myapp',
googleServicesFile: './GoogleService-Info.plist',
},
plugins: [
[
'react-native-notify-kit',
{
ios: {
notificationServiceExtension: true,
},
},
],
],
},
};
Use the object form only when you need a custom target name or bundle suffix:
export default {
expo: {
name: 'MyApp',
slug: 'my-app',
ios: {
bundleIdentifier: 'com.example.myapp',
},
plugins: [
[
'react-native-notify-kit',
{
ios: {
notificationServiceExtension: {
enabled: true,
targetName: 'NotifyKitNSE',
bundleSuffix: '.NotifyKitNSE',
},
},
},
],
],
},
};
The object form is explicit: set enabled: true. An object without enabled: true is treated as disabled.
What the iOS plugin changes
During Expo prebuild, the plugin:
- upserts
extra.eas.build.experimental.ios.appExtensionswith the NSE target and bundle identifier - generates
NotificationService.swift - generates the NSE
Info.plist - generates an entitlements file
- patches the Xcode project with the
NotifyKitNSEtarget - adds the host app dependency on the NSE target
- embeds
NotifyKitNSE.appexin the host app's Copy Files phase - patches the Podfile with a top-level
NotifyKitNSEtarget andpod 'RNNotifeeCore' - mirrors static
use_frameworks!linkage when Expo/Firebase static frameworks require it
The default bundle identifier is <ios.bundleIdentifier>.NotifyKitNSE. The plugin requires ios.bundleIdentifier when ios.notificationServiceExtension is enabled.
iOS prerequisites
- Use Expo prebuild,
expo run:ios, or EAS development builds. Expo Go cannot load the generated native extension. - Add
GoogleService-Info.plistto the Expo project and pointios.googleServicesFileat it if your Firebase setup expects Expo to copy it. - Configure APNs key or certificates in Firebase Console.
- Configure Apple signing, capabilities, and any EAS credentials needed by your project and the generated app extension.
- Test remote pushes on a physical iPhone. The simulator is not a reliable gate for remote-push NSE behavior.
Typical local flow:
npx expo prebuild --platform ios
npx expo run:ios --device
For EAS:
eas build --profile development --platform ios
iOS work that remains manual
- Firebase project setup
- APNs setup in Firebase
- Apple signing and capability review when your project uses custom provisioning or App Groups
- EAS credentials if your build profile does not auto-manage them
- Any custom NSE logic you add after generation
Android Expo setup
The NotifyKit plugin does not configure Firebase or RNFirebase on Android. Configure the Expo app as a normal RNFirebase development build:
- Register a Firebase Android app whose package matches
expo.android.package, for examplecom.example.yourapp. - Place
google-services.jsonin your Expo project. - Point
android.googleServicesFileat that file. - Add the RNFirebase Expo plugins required by your project, typically
@react-native-firebase/appand@react-native-firebase/messaging. - Build a development build with Expo prebuild,
expo run:android, or EAS.
Minimal Expo config shape:
export default {
expo: {
name: 'MyApp',
slug: 'my-app',
android: {
package: 'com.example.yourapp',
googleServicesFile: './google-services.json',
},
plugins: [
'@react-native-firebase/app',
'@react-native-firebase/messaging',
'react-native-notify-kit',
],
},
};
Android FCM Mode uses data-only messages. Do not use an Android notification payload for NotifyKit FCM Mode, because the FCM SDK can auto-display it before NotifyKit handles it.
Create the Android channel used by your payloads before displaying notifications, configure a fallback channel if needed, and wire both RNFirebase receive paths. defaultChannelId must point to an existing Android channel.
import messaging from '@react-native-firebase/messaging';
import notifee, { AndroidImportance } from 'react-native-notify-kit';
await notifee.createChannel({
id: 'default',
name: 'Default',
importance: AndroidImportance.HIGH,
});
await notifee.setFcmConfig({
defaultChannelId: 'default',
defaultPressAction: { id: 'default', launchActivity: 'default' },
});
messaging().setBackgroundMessageHandler(async remoteMessage => {
await notifee.handleFcmMessage(remoteMessage);
});
messaging().onMessage(async remoteMessage => {
await notifee.handleFcmMessage(remoteMessage);
});
For background data-only validation, send high-priority Android FCM messages, for example options.androidPriority: 'high' when using buildNotifyKitPayload. Delivery still depends on FCM priority, device state, Doze, and OEM policy.
Android foreground service config plugin
Android foreground service manifest configuration is opt-in through the NotifyKit config plugin. If android.foregroundService is omitted, the Android manifest is unchanged and no foreground service type is chosen for you.
Single type:
export default {
expo: {
plugins: [
[
'react-native-notify-kit',
{
android: {
foregroundService: {
types: ['shortService'],
},
},
},
],
],
},
};
Multiple types:
export default {
expo: {
plugins: [
[
'react-native-notify-kit',
{
android: {
foregroundService: {
types: ['dataSync', 'remoteMessaging'],
},
},
},
],
],
},
};
specialUse requires an explicit Play-policy subtype:
export default {
expo: {
plugins: [
[
'react-native-notify-kit',
{
android: {
foregroundService: {
types: ['specialUse'],
specialUseSubtype: 'Explain the user-visible special foreground service use case',
},
},
},
],
],
},
};
When enabled, the plugin writes app.notifee.core.ForegroundService, android:foregroundServiceType, the base FOREGROUND_SERVICE permission, type-specific FOREGROUND_SERVICE_* permissions where Android defines them, and android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE when specialUse is used.
For 10.4.0, the Android Expo foreground service config plugin was validated with a real prebuild and a runtime shortService smoke on a Pixel 9 Pro XL. The runtime gate covered displayNotification with asForegroundService, registerForegroundService, stopForegroundService, cancelNotification, and dumpsys confirmation of types=0x00000800 / isShortFgs=true.
The plugin does not add USE_EXACT_ALARM, does not add USE_FULL_SCREEN_INTENT, does not configure Firebase, and does not add Maven repositories or Gradle workarounds. Runtime permissions such as camera, location, microphone, media projection, and notification permission remain the app's responsibility. Google Play foreground service policy compliance is also the app developer's responsibility.
Server SDK reference
Import from react-native-notify-kit/server. Runs in Node.js 22+ and Firebase Cloud Functions. Zero runtime dependencies.
buildNotifyKitPayload(input): NotifyKitPayloadOutput
Builds a complete FCM HTTP v1 Message object ready for admin.messaging().send(). Validates input, serializes the notifee_options blob, and emits both the Android data-only half and the iOS APNs alert half.
const message = buildNotifyKitPayload({
token: 'eZ...device token',
notification: { id, title, body, data?, android?, ios? },
options: { androidPriority?, iosBadgeCount?, ttl?, collapseKey? },
});
Input type:
type NotifyKitPayloadInput = {
// Exactly one of these three:
token?: string; // single device
topic?: string; // FCM topic (e.g. 'news', 'sports')
condition?: string; // FCM condition expression
notification: NotifyKitNotification;
options?: NotifyKitOptions;
};
type NotifyKitNotification = {
id?: string; // also used as collapse key unless options.collapseKey is set
title: string; // required, non-empty
body: string; // required, non-empty
data?: Record<string, string>;
android?: NotifyKitAndroidConfig;
ios?: NotifyKitIosConfig;
};
type NotifyKitAndroidConfig = {
channelId?: string;
smallIcon?: string;
largeIcon?: string;
color?: string;
pressAction?: { id: string; launchActivity?: string };
actions?: Array<{ title: string; pressAction: { id; launchActivity? }; input?: boolean }>;
style?: { type: 'BIG_TEXT'; text: string } | { type: 'BIG_PICTURE'; picture: string };
};
type NotifyKitIosConfig = {
sound?: string;
categoryId?: string;
threadId?: string;
interruptionLevel?: 'passive' | 'active' | 'timeSensitive' | 'critical';
attachments?: Array<{ url: string; identifier?: string }>;
};
type NotifyKitOptions = {
androidPriority?: 'high' | 'normal';
iosBadgeCount?: number; // non-negative integer
ttl?: number; // seconds, positive integer
collapseKey?: string;
};
The returned value is a valid FCM Message:
type NotifyKitPayloadOutput = {
token?: string;
topic?: string;
condition?: string;
data: Record<string, string>; // your data keys + notifee_options (+ notifee_data when > 5 keys)
android: {
priority: 'HIGH' | 'NORMAL';
collapse_key?: string;
ttl?: string; // '3600s' format
};
apns: {
headers: {
'apns-push-type': 'alert';
'apns-priority': '10';
'apns-collapse-id'?: string;
'apns-expiration'?: string;
};
payload: {
aps: {
alert: { title; body };
'mutable-content': 1;
sound?;
category?;
'thread-id'?;
'interruption-level'?;
badge?;
};
notifee_options: string; // JSON blob, see Payload reference
notifee_data?: string;
};
};
sizeBytes: number; // non-enumerable — not serialized with JSON.stringify
};
sizeBytes is defined as non-enumerable: it's accessible on the returned object for your own diagnostics, but JSON.stringify(message) strips it, so it never leaks onto the wire.
Other exports
import {
buildNotifyKitPayload, // main entry
buildAndroidPayload, // android half only — for custom merging
buildIosApnsPayload, // iOS half only — for custom merging
serializeNotifeeOptions, // JSON-serialize the blob directly
} from 'react-native-notify-kit/server';
import type {
NotifyKitPayloadInput,
NotifyKitPayloadOutput,
NotifyKitNotification,
NotifyKitOptions,
NotifyKitAndroidConfig,
NotifyKitIosConfig,
NotifyKitPressAction,
NotifyKitAndroidAction,
NotifyKitAndroidStyle,
NotifyKitIosAttachment,
NotifyKitIosInterruptionLevel,
// raw FCM output shapes
NotifyKitAndroidOutput,
NotifyKitApnsOutput,
NotifyKitApnsAps,
NotifyKitApnsHeaders,
NotifyKitApnsPayload,
SerializedNotifeeOptions,
ApnsInterruptionLevel,
} from 'react-native-notify-kit/server';
Validation rules
Every error is thrown synchronously from buildNotifyKitPayload. Error messages are prefixed with [react-native-notify-kit/server] so they're grep-able in Cloud Functions logs.
| Rule | Error message |
|---|---|
| Input must be an object | Validation: input must be an object |
Exactly one of token / topic / condition | Routing: exactly one of 'token', 'topic', or 'condition' must be provided. Got: <n> |
token non-empty string | Routing: 'token' must be a non-empty string |
topic non-empty string | Routing: 'topic' must be a non-empty string |
condition non-empty string | Routing: 'condition' must be a non-empty string |
notification required | Validation: 'notification' is required and must be an object |
notification.id non-empty when provided | Validation: notification.id must be a non-empty string when provided |
notification.title required | Validation: notification.title is required and must be a non-empty string |
notification.body required | Validation: notification.body is required and must be a non-empty string |
notification.data must be an object | Validation: 'notification.data' must be an object |
data values must be strings | Validation: FCM data values must be strings. Got <type> for key '<key>'. Use JSON.stringify() if you need to pass complex values. |
| Reserved keys rejected | Validation: 'notifee_options' and 'notifee_data' are reserved keys and cannot be used in notification.data |
| iOS attachments array | iOS: 'notification.ios.attachments' must be an array |
| iOS attachment shape | iOS: each attachment must be an object with a string 'url' field |
| iOS attachment https-only | iOS: iOS attachments require https:// URLs. Got: <url> |
| iOS interruptionLevel enum | Validation: invalid interruptionLevel '<value>'. Expected one of: passive, active, timeSensitive, critical |
options must be an object | Validation: 'options' must be an object |
options.androidPriority enum | Validation: 'options.androidPriority' must be 'high' or 'normal'. Got: <value> |
options.iosBadgeCount non-negative int | Validation: 'options.iosBadgeCount' must be a non-negative integer |
options.ttl positive int | Validation: options.ttl must be a positive integer (seconds). Got: <value> |
options.collapseKey non-empty | Validation: 'options.collapseKey' must be a non-empty string |
| Blob must be serializable | Serialization: notifee_options contains circular references or non-serializable values |
Note on
ttl: 0. Zero is rejected because it's semantically ambiguous ("never expire" vs "expire immediately") and FCM's HTTP v1 API uses the same string format for both concepts. Omitttlentirely to use FCM's default (4 weeks), or pass a positive integer in seconds.
Note on
firebase-adminTTL compatibility.buildNotifyKitPayloademitsandroid.ttlin FCM HTTP v1 wire format ("3600s"), which is what the FCM REST API expects.firebase-admin'sadmin.messaging().send()validates input in the SDK layer before serializing and expectsttlas a number of milliseconds (3_600_000). If you route throughfirebase-adminand passoptions.ttl, normalize it before sending:const message = buildNotifyKitPayload(input); if (typeof message.android.ttl === 'string') { const match = message.android.ttl.match(/^(\d+)s$/); if (match) (message.android as any).ttl = Number(match[1]) * 1000; } await admin.messaging().send(message);See
scripts/send-test-fcm.jsin this repo for the reference adapter.
Payload size
FCM has a 4 KB hard limit per message (the HTTP v1 Message JSON, not just your data map). The server SDK emits a console.warn when the serialized payload exceeds ~3500 bytes — enough headroom for FCM's own wrapping. Size is measured with Buffer.byteLength(json, 'utf8'), so emoji and CJK characters are counted correctly.
[react-native-notify-kit/server] Payload size 3612 bytes approaches FCM 4KB limit. Consider reducing notifee_options.
Read output.sizeBytes for programmatic checks:
const message = buildNotifyKitPayload(input);
if (message.sizeBytes > 3500) {
// fall back to a smaller payload, split across two messages, or switch to a
// backend fetch (push a small "something new" nudge and fetch the full
// content from your API when the app opens)
}
Reserved keys
These keys are rejected in notification.data (the SDK throws):
notifee_optionsnotifee_data
These keys are preserved by the SDK but have special meaning on the client:
- Anything matching FCM's own denylist (
from,collapse_key,message_type,message_id,aps,fcm_options, and prefix filtersgoogle.,gcm.,fcm.,android.,notifee) — the FCM SDK strips these before delivery anyway.
Firebase Cloud Functions example
// functions/src/index.ts
import { onDocumentCreated } from 'firebase-functions/v2/firestore';
import { buildNotifyKitPayload } from 'react-native-notify-kit/server';
import * as admin from 'firebase-admin';
admin.initializeApp();
export const onOrderCreated = onDocumentCreated('orders/{orderId}', async event => {
const order = event.data?.data();
if (!order?.deviceToken) return;
const message = buildNotifyKitPayload({
token: order.deviceToken,
notification: {
id: `order-${event.params.orderId}`,
title: 'Order received',
body: `We're preparing ${order.itemName}.`,
data: { orderId: event.params.orderId },
android: { channelId: 'orders' },
ios: { sound: 'default', interruptionLevel: 'timeSensitive' },
},
options: { androidPriority: 'high', ttl: 3600 },
});
await admin.messaging().send(message);
});
Client API reference
Import the default notifee module — handleFcmMessage and setFcmConfig are instance methods on the singleton.
notifee.handleFcmMessage(remoteMessage): Promise<string | null>
Processes an FCM remote message produced by the server SDK and displays a Notify Kit notification according to the embedded notifee_options. Safe to call from both setBackgroundMessageHandler and onMessage.
Returns: the displayed notification ID, or null if the call was an intentional no-op.
Behavior matrix:
| Platform | App state | Payload | Behavior |
|---|---|---|---|
| Android | foreground | with notifee_options | displayNotification(...) — notification appears |
| Android | background | with notifee_options | displayNotification(...) — notification appears |
| Android | killed | with notifee_options | best-effort: when FCM wakes the app, the headless task runs and calls displayNotification(...) |
| Android | any | no notifee_options and fallbackBehavior: 'display' (default) | minimal notification built from remoteMessage.notification / remoteMessage.data.title / remoteMessage.data.body |
| Android | any | no notifee_options and fallbackBehavior: 'ignore' | returns null, no display |
| iOS | foreground | with notifee_options | displayNotification(...) — in-app banner (skipped if suppressForegroundBanner) |
| iOS | background | with notifee_options | returns null — NSE already displayed |
| iOS | killed | with notifee_options | returns null — NSE already displayed |
The iOS background/killed no-op is deliberate: the Notification Service Extension has already drawn the final notification using the same notifee_options blob, and a second displayNotification call would duplicate it.
Input type:
type FcmRemoteMessage = {
messageId?: string;
data?: Record<string, string>;
notification?: { title?: string; body?: string };
};
This is a structural type — handleFcmMessage doesn't import @react-native-firebase/messaging, so you can pass a RemoteMessage from that library directly, or any compatible shape if you use a different push SDK.
Thrown errors:
notifee.handleFcmMessage(*) 'remoteMessage' expected an object.— invalid argument.
Console warnings (non-fatal, listed so you can grep logs):
[react-native-notify-kit] Failed to parse notifee_options: <detail>. Falling back to raw title/body.[react-native-notify-kit] notifee_options parsed to a non-object value. Falling back to raw title/body.[react-native-notify-kit] notifee_options version <N> is newer than supported version 1. Display may be incomplete.[react-native-notify-kit] android.style.type '<type>' present but required '<field>' field missing or not a string. Style ignored.[react-native-notify-kit] Unknown android.style.type '<type>'. Style ignored.[react-native-notify-kit] Unknown ios.interruptionLevel '<level>'. Ignored.[react-native-notify-kit] ios.attachments entry has missing or empty url. Skipped.[react-native-notify-kit] Failed to parse notifee_data. Using top-level data keys only.[react-native-notify-kit] handleFcmMessage: displaying notification with empty title and body. Check your FCM payload.[react-native-notify-kit] handleFcmMessage: Android fallback path has no channelId (no payload channelId, no defaultChannelId configured). Notification may be dropped by the OS.
notifee.setFcmConfig(config): Promise<void>
Sets defaults that handleFcmMessage consults when the payload leaves a field unset. Call once at app startup, typically in index.js before AppRegistry.registerComponent. Resolves synchronously; the Promise return is there so a future release can persist config across cold starts without a breaking API change.
Config type:
type FcmConfig = {
/** Used when notifee_options.android.channelId is absent. */
defaultChannelId?: string;
/** Used when notifee_options.android.pressAction is absent. */
defaultPressAction?: { id: string; launchActivity?: string };
/**
* What to do when remoteMessage.data.notifee_options is entirely missing.
* - 'display': build a minimal notification from remoteMessage.notification.
* - 'ignore': return null.
* @default 'display'
*/
fallbackBehavior?: 'display' | 'ignore';
ios?: {
/** When true, foreground notifications from handleFcmMessage are not displayed. */
suppressForegroundBanner?: boolean;
};
};
Throws notifee.setFcmConfig(*) config must be a plain object. Got: <type> when called with null, an array, or a non-object value.
The nested ios sub-object is deep-copied on both setFcmConfig and every handleFcmMessage entry, so mutating the config you passed in doesn't leak through to subsequent calls.
Example: full startup wiring
// index.js
import { AppRegistry } from 'react-native';
import messaging from '@react-native-firebase/messaging';
import notifee, { AndroidImportance } from 'react-native-notify-kit';
import App from './App';
async function bootstrap() {
await notifee.requestPermission();
await notifee.createChannel({
id: 'orders',
name: 'Orders',
importance: AndroidImportance.HIGH,
});
await notifee.createChannel({
id: 'default',
name: 'Default',
importance: AndroidImportance.DEFAULT,
});
await notifee.setFcmConfig({
defaultChannelId: 'default',
defaultPressAction: { id: 'default', launchActivity: 'default' },
fallbackBehavior: 'display',
ios: { suppressForegroundBanner: false },
});
}
void bootstrap();
messaging().setBackgroundMessageHandler(async remoteMessage => {
await notifee.handleFcmMessage(remoteMessage);
});
AppRegistry.registerComponent('MyApp', () => App);
iOS NSE setup
iOS requires a Notification Service Extension (NSE) to rewrite incoming APNs notifications before display. FCM Mode has two automated setup paths:
- Expo CNG / prebuild: use the config plugin.
- Bare React Native: use the
init-nseCLI.
Expo CNG setup
Use the Expo CNG / development builds setup above. The plugin runs during prebuild and generates the Swift service, Info.plist, entitlements, Xcode target, host dependency, .appex embed phase, EAS appExtensions entry, and Podfile target.
Do not use npx react-native-notify-kit init-nse as the primary Expo setup path. Generated Expo native folders are disposable, so the plugin is the durable source of truth.
Bare React Native CLI setup
npx react-native-notify-kit init-nse
cd ios && pod install
What the bare CLI does:
- Auto-detects your iOS project (
ios/*.xcodeprojor.xcworkspace) and your main app target's bundle ID. - Creates three files under
ios/NotifyKitNSE/:NotificationService.swift— callsNotifeeExtensionHelper.populateNotificationContent(...)to applynotifee_options.Info.plist— setsNSExtensionPointIdentifier = com.apple.usernotifications.serviceand the principal class.NotifyKitNSE.entitlements— empty file (extend if you need App Groups for cross-target data sharing).
- Patches
ios/Podfile— adds atarget 'NotifyKitNSE'block nested inside your app target withinherit! :search_paths. TheRNNotifeeCorepod is added as a dependency of the NSE target only (not the main app) to avoid the duplicate-symbols linker error documented in 9.1.22. - Patches
ios/YourApp.xcodeproj/project.pbxproj— adds the NSE native target with build phases, inherits signing from the parent target, and setsPRODUCT_BUNDLE_IDENTIFIER = <parent-bundle-id>.NotifyKitNSE. - Backs up the Podfile and
.pbxprojbefore every edit (PID-stamped backups, atomic writes, rollback on failure).
Open Xcode after pod install, verify the NotifyKitNSE target's signing (it should inherit from your app target), build, and you're done.
CLI reference
npx react-native-notify-kit init-nse [options]
| Option | Default | Description |
|---|---|---|
--ios-path <path> | auto-detect | Path to your iOS directory (e.g. ios/). |
--target-name <name> | NotifyKitNSE | NSE target name. Must match /^[A-Za-z0-9_\-.]+$/. |
--bundle-suffix <str> | .NotifyKitNSE | Suffix appended to the parent bundle ID. Must match /^\.[A-Za-z0-9\-.]+$/ (starts with .). |
-f, --force | false | Overwrite an existing NSE target. Without this flag, the CLI fails fast if NotifyKitNSE already exists. |
-n, --dry-run | false | Print the actions that would be taken, without writing. |
Validation errors (exact text):
Invalid target name '<name>'. Must match [A-Za-z0-9_-.]\n Target names can only contain letters, digits, underscores, hyphens, and dots.— reject target names with special chars.Invalid bundle suffix '<suffix>'. Must start with '.' and contain only letters, digits, hyphens, and dots.NSE target '<name>' already exists in <where>.\n Use --force to overwrite or --target-name to use a different name.
Parent bundle ID with variables. If your main app target sets PRODUCT_BUNDLE_IDENTIFIER via an Xcode build variable (e.g. $(PRODUCT_BUNDLE_PREFIX).$(PRODUCT_NAME)), the CLI logs a warning and writes the literal variable into the NSE bundle ID — you'll need to set the NSE's bundle ID manually in Xcode. This shows up as:
Parent bundle ID uses a variable: $(PRODUCT_BUNDLE_PREFIX).MyApp
The NSE bundle ID will need to be set manually in Xcode.
Manual setup
If automation does not work for your bare React Native project because of heavily customized Xcode configs or exotic monorepo layouts, the legacy manual guide walks through the Xcode steps. You'll still use the same NotifeeExtensionHelper.populateNotificationContent(...) call; only the scaffolding differs.
What the Swift template does
The generated NotificationService.swift is under your ownership after generation. Regenerating with CLI --force or rebuilding disposable Expo native folders can overwrite it, so if you customize it, keep the populateNotificationContent call intact. The template:
- Implements
didReceive(_:withContentHandler:). - Hands the request to
NotifeeExtensionHelper.populateNotificationContent(...)— the ObjC helper shipped inRNNotifeeCorethat readsnotifee_optionsand applies attachments, category, thread-id, and sound. - Logs
[NotifyKitNSE] didReceive ...and[NotifyKitNSE] contentHandler ...viaNSLog, viewable in Console.app with filtersubsystem:NotifyKitNSEwhen attaching to your NSE target. - Implements
serviceExtensionTimeWillExpireas a safety net — iOS gives the NSE ~30 seconds before killing it; if an attachment download stalls, the NSE delivers whatever it has so far instead of dropping the notification entirely.
No bridging header needed
The NSE is pure Swift. RNNotifeeCore exposes NotifeeExtensionHelper as an Objective-C class with NS_SWIFT_NAME hints, so Swift imports it directly (import RNNotifeeCore). No NotifyKitNSE-Bridging-Header.h is required.
Deployment target
The NSE target defaults to iOS 15.1, matching the main library deployment target. If your main app targets a higher version, update the NSE target in Xcode → Build Settings → Deployment → iOS Deployment Target.
Debugging the NSE
Attach to the running NSE process from Xcode:
- Run the main app on device.
- In Xcode: Debug → Attach to Process →
NotifyKitNSE(appears after the first push arrives and spawns the extension). - Send a push. Set breakpoints in
NotificationService.swiftor log withNSLog.
You can also read NSE logs in Console.app — filter by process NotifyKitNSE. The template emits two log lines per normal invocation (entry + completion), plus a third on the timeout path:
[NotifyKitNSE] didReceive id=... title=... hasNotifeeOptions=true requestedAttachments=1 urls=https://...
[NotifyKitNSE] contentHandler id=... title=... deliveredAttachments=1 identifiers=notifee-attachment-0
[NotifyKitNSE] serviceExtensionTimeWillExpire id=... title=... deliveredAttachments=0
If hasNotifeeOptions=false, the server didn't send a NotifyKit-shaped payload — either you're not using buildNotifyKitPayload, or the payload was stripped by a proxy.
Data-only delivery
The server SDK always emits an Android data-only message — there's no notification field in the FCM payload. That's what makes setBackgroundMessageHandler / onMessage fire instead of the FCM SDK auto-displaying.
Channels are your responsibility
handleFcmMessage honors whatever channelId the server sends, falling back to defaultChannelId from setFcmConfig. The channel must exist before the notification is displayed — create channels at app startup:
await notifee.createChannel({
id: 'orders',
name: 'Orders',
importance: AndroidImportance.HIGH,
sound: 'default',
// If you want a custom sound:
// sound: 'my_custom_sound', // file at android/app/src/main/res/raw/my_custom_sound.mp3
});
Android: The
NotificationChannelsound is immutable after creation. To change the sound you must delete and recreate the channel under a new ID. See the custom sounds note in the main README.
Style mapping
Server-side style enums are strings ('BIG_TEXT', 'BIG_PICTURE') to survive JSON serialization. On the client, handleFcmMessage maps them to AndroidStyle.BIGTEXT / AndroidStyle.BIGPICTURE:
// Server
android: {
channelId: 'news',
style: { type: 'BIG_PICTURE', picture: 'https://cdn.example.com/banner.png' },
}
// Client receives (and calls displayNotification with):
android: {
channelId: 'news',
style: { type: AndroidStyle.BIGPICTURE, picture: 'https://cdn.example.com/banner.png' },
}
Other AndroidStyle values (MESSAGING, INBOX, CALL) aren't wired through the server SDK yet — they have richer schemas that need future versioning. Build them yourself via displayNotification until then.
Action buttons
android: {
actions: [
{ title: 'Accept', pressAction: { id: 'accept-order' } },
{ title: 'Decline', pressAction: { id: 'decline-order' } },
{ title: 'Reply', pressAction: { id: 'reply' }, input: true },
],
}
Handle the action id in your onBackgroundEvent / onForegroundEvent listener:
notifee.onBackgroundEvent(async ({ type, detail }) => {
if (type === EventType.ACTION_PRESS && detail.pressAction?.id === 'accept-order') {
// ...
}
});
Foreground delivery
When the app is in foreground, Android shows a normal notification in the tray (the library doesn't have the iOS "in-app banner" concept). To suppress foreground display, check AppState yourself and branch:
messaging().onMessage(async remoteMessage => {
if (AppState.currentState === 'active') {
// Route to an in-app toast instead of a tray notification
showInAppToast(remoteMessage.notification);
return;
}
await notifee.handleFcmMessage(remoteMessage);
});
Full notifee_options schema
{
"_v": 1,
"title": "Order received",
"body": "We're preparing your food.",
"android": {
"channelId": "orders",
"smallIcon": "ic_notification",
"largeIcon": "https://cdn.example.com/avatar.png",
"color": "#4CAF50",
"pressAction": { "id": "open-order", "launchActivity": "default" },
"actions": [
{ "title": "Accept", "pressAction": { "id": "accept" } },
{ "title": "Decline", "pressAction": { "id": "decline" }, "input": true },
],
"style": { "type": "BIG_TEXT", "text": "Long body text ..." },
},
"ios": {
"sound": "default",
"categoryId": "ORDER_UPDATE",
"threadId": "orders-thread",
"interruptionLevel": "timeSensitive",
"attachments": [
{ "url": "https://cdn.example.com/orders/42.png", "identifier": "attachment-0" },
],
},
}
_v version field
Every blob carries _v: 1. When a future client encounters _v > 1, it parses what it understands and logs:
[react-native-notify-kit] notifee_options version 2 is newer than supported version 1. Display may be incomplete.
Bump client react-native-notify-kit to pick up new fields — old clients never crash, they just miss the new fields.
Reserved top-level FCM data keys
The FCM SDK strips these keys before your setBackgroundMessageHandler / onMessage handler sees them:
- Prefixes:
android.,google.,gcm.,fcm. notifeeprefix (without trailing dot — the library's namespace, sonotifeeFoois also filtered)- Exact:
from,collapse_key,message_type,message_id,aps,fcm_options
The server SDK additionally rejects notifee_options and notifee_data in your notification.data to prevent collisions with the transport blob. See the main README for the iOS / Android divergence on bare-fcm keys.
4 KB FCM limit
FCM enforces a hard 4 KB limit on the entire serialized message. The server SDK warns at ~3500 bytes; common causes of going over:
- Long
bodytext — use an AndroidBIG_TEXTstyle instead, which doesn't contribute to the size cap if the full text is inline but the banner text is short. - Many
datakeys — collapse nested objects into a single JSON string (JSON.stringify) and parse on the client. The reserved keysnotifee_options/notifee_dataare off-limits. - Long attachment URLs — shorten via a CDN or URL shortener.
If you're close to 4 KB, send a nudge payload instead: push just an ID, and have the client fetch the full content from your API when it handles the push.
Migration from the manual pattern
If you currently do this:
// OLD — manual pattern with data-only on both platforms
messaging().setBackgroundMessageHandler(async remoteMessage => {
await notifee.displayNotification({
title: remoteMessage.data.title,
body: remoteMessage.data.body,
android: { channelId: remoteMessage.data.channelId || 'default' },
});
});
…you're running into iOS silent-push throttling (30–60% loss) and hand-rolling the payload shape. The FCM Mode migration is:
Step 1 — Server
Replace your custom payload builder with buildNotifyKitPayload. If you were already sending data-only on iOS, the iOS half changes (you'll now emit an alert payload). Old payload:
// OLD — manual
await admin.messaging().send({
token,
data: { title: '...', body: '...', channelId: 'orders' },
apns: { payload: { aps: { 'content-available': 1 } } }, // silent push
});
New payload:
// NEW — FCM Mode
await admin.messaging().send(
buildNotifyKitPayload({
token,
notification: { title: '...', body: '...', android: { channelId: 'orders' } },
}),
);
Step 2 — Client
Swap displayNotification for handleFcmMessage:
// OLD
messaging().setBackgroundMessageHandler(async (m) => {
await notifee.displayNotification({ title: m.data.title, body: m.data.body, ... });
});
// NEW
messaging().setBackgroundMessageHandler(async (m) => {
await notifee.handleFcmMessage(m);
});
And configure defaults once at startup:
notifee.setFcmConfig({ defaultChannelId: 'default' });
Step 3 — iOS
For Expo CNG / development builds, add the config plugin and run prebuild. For bare React Native, run the CLI: npx react-native-notify-kit init-nse && cd ios && pod install. If you already had a Notify Kit Service Extension (e.g. from the legacy ObjC guide), you can either keep it and skip this step, or regenerate with --force to get the new Swift template.
Compatibility during migration
Old clients on old payloads keep working: the manual displayNotification path is unchanged. FCM Mode uses a new data key (notifee_options) that old clients don't read, so there's no on-the-wire breakage. You can roll out the server change first, then the client — old clients will continue to use remoteMessage.data.title / .body (FCM Mode's fallback path).
Expo Go does not work
Expo Go cannot load react-native-notify-kit native code, the generated iOS NSE target, or the embedded .appex. Use Expo CNG / prebuild with a development build or EAS build.
Expo prebuild did not generate NotifyKitNSE
-
Confirm the plugin is listed as
react-native-notify-kitinexpo.plugins. -
Confirm
ios.bundleIdentifieris set. The plugin requires it to derive the NSE bundle identifier. -
Run a clean prebuild if the native folders were generated before adding the plugin:
npx expo prebuild --clean --platform ios -
Inspect
ios/NotifyKitNSE/, the Xcode project target list, andextra.eas.build.experimental.ios.appExtensionsin the resolved Expo config.
iOS notification not appearing in background
- Check the Notification Service Extension is installed. In Xcode, look for the
NotifyKitNSEtarget. If missing, use the Expo config plugin and rerun prebuild for Expo, or runnpx react-native-notify-kit init-nsefor bare React Native. - Check NSE signing. Targets →
NotifyKitNSE→ Signing & Capabilities. Team must match the main app; provisioning profile must cover<your-bundle-id>.NotifyKitNSE. - Check
aps-push-type: alertandmutable-content: 1are present. Both are emitted automatically bybuildNotifyKitPayload; if they're missing, a middleware / proxy is stripping them. - Attach to the NSE process in Xcode (Debug → Attach to Process →
NotifyKitNSE) and confirmdidReceivefires. If nothing fires, APNs isn't routing to your NSE.
Android duplicate notifications
If you see two notifications per push on Android, the FCM SDK is auto-displaying the alert AND your handleFcmMessage is displaying a second one. Causes:
- You're sending the push without
buildNotifyKitPayload— something in your pipeline is setting anotificationfield on the Android message. FCM Mode always usesdata-only on Android; check your payload viagcloud logging read 'resource.type="logging_sink"'or Firebase Console. - You have an older client (< 10.0.0) handling the message alongside a newer client. Bump all clients.
NSE not activating
- Run the app on a real iOS device (NSEs don't run on the simulator for remote pushes — local notifications only).
- Check
aps-push-typeisalertandmutable-contentis1. The server SDK always sets both. - Attach to the NSE process in Xcode and send a test push via
node scripts/send-test-fcm.ts(requiresGOOGLE_APPLICATION_CREDENTIALS+ device token). IfdidReceivenever fires, the NSE isn't linked — verify Xcode → General → Frameworks, Libraries, and Embedded Content listsRNNotifeeCore.framework(or the CocoaPods static-library equivalent). - For Expo, run a clean prebuild after changing plugin options and verify the generated
.appexis embedded in the host app'sPlugInsdirectory after build.
Custom sound not playing
Custom sounds have platform-specific requirements that don't go through the Notify Kit JS API when FCM delivers the push.
- iOS: the sound file must be bundled in the NSE target's resources, not (only) the main app. Drag the file into the
NotifyKitNSE/folder in Xcode and verify it appears in the NSE target's Build Phases → Copy Bundle Resources. - Android: the
NotificationChannelsound is locked at channel creation.notifee_options.android.soundfrom FCM Mode overrides the channel sound only if the channel was created with that sound already. To change the sound, create a new channel under a new ID.
See the main README's custom sounds section for the full background.
pod install fails after NSE generation
If pod install errors with "Unable to find a specification for RNNotifeeCore" or similar:
- Verify
node_modules/react-native-notify-kit/exists and includes aRNNotifeeCore.podspec. - Run
pod install --repo-updatefromios/. - If you use Firebase with static frameworks, confirm the NSE target uses compatible
use_frameworks! :linkage => :staticlinkage. The Expo plugin mirrors the detected Expo/Firebase static-framework setup when needed. - For bare React Native CLI output, the NSE block is normally nested inside the main app target with
inherit! :search_paths. - For Expo plugin output, the NSE block is a top-level target and should not inherit the Expo host target module graph.
- If it still fails, rerun a clean Expo prebuild or rerun
npx react-native-notify-kit init-nse --forcefor bare React Native on a clean Podfile, then inspect the generated Podfile before applying local customizations.
Xcode reports Cycle inside <AppName> after adding the NSE
This can happen when the host app embeds NotifyKitNSE.appex and React Native Firebase adds its [RNFB] Core Configuration build phase. Current Expo plugin and init-nse versions add a Podfile post_install patch that removes the problematic RNFB input path after each pod install.
If the cycle persists, update Notify Kit. For Expo, run a clean prebuild. For bare React Native, rerun npx react-native-notify-kit init-nse, then run pod install again from ios/. Avoid editing React Native Firebase build phases manually unless your project has additional custom build-phase inputs that still create a cycle.
iOS attachment metadata exists but the attachment is not visible
- Confirm every attachment URL is HTTPS. The server SDK rejects non-HTTPS iOS attachment URLs.
- Confirm the URL is reachable by the device without app authentication.
- Check NSE logs in Console.app by filtering for process
NotifyKitNSE. - Inspect
getDisplayedNotifications()forios.attachmentsmetadata. Metadata proves the NotifyKit payload path ran, but visual lock-screen/banner rendering still depends on iOS attachment download and presentation rules.
handleFcmMessage returns null
By design, when:
- iOS + app in background or killed (NSE owns display).
fallbackBehavior: 'ignore'and the payload has nonotifee_options.ios.suppressForegroundBanner: trueand the app is in iOS foreground.- The
remoteMessagewasn't produced by NotifyKit and you opted out of the fallback.
If you're seeing null unexpectedly, check remoteMessage.data.notifee_options — it should be a JSON string. If it's missing, your server isn't using buildNotifyKitPayload.
Notification appears but tap doesn't open app
On Android, set a pressAction:
android: { pressAction: { id: 'default', launchActivity: 'default' } }
Or call notifee.setFcmConfig({ defaultPressAction: { id: 'default', launchActivity: 'default' } }) at startup.
Since 9.3.0 the library injects this default at the native layer for displayNotification, so handleFcmMessage tap behavior works without explicit config — but set it if you want the tap to route to a non-default activity. See the main README section on Android pressAction.
Payload too large
The server SDK warns at ~3500 bytes. Common fixes:
- Shorten
bodyor move long text to AndroidBIG_TEXTstyle. - Collapse nested
datavalues into a single JSON string. - Switch to a nudge-then-fetch pattern (push an ID, fetch the payload on open).
Known limitations
- iOS background
DELIVEREDevent gap. When a push arrives while your app is in background/killed on iOS,EventType.DELIVEREDis not emitted toonBackgroundEvent— the NSE draws the notification and the main app process never wakes. This is a platform limitation (noUNUserNotificationCenterDelegatecallback fires for NSE-drawn notifications until the user taps). Android emitsDELIVEREDunconditionally. To detect background delivery on iOS, checkgetDisplayedNotifications()when the app returns to foreground, or have the NSE write to a shared App Group container. - No deep validation of nested
android/iosconfig. The server SDK validates the top-level shape (routing, data value types, iOS attachment URLs, ttl, etc.) but trusts TypeScript structural typing for the nestedNotifyKitAndroidConfig/NotifyKitIosConfig. JavaScript callers that bypass TypeScript should validate themselves. Deep runtime validation is planned. - Style types limited to
BIG_TEXT/BIG_PICTURE.MESSAGING,INBOX, andCALLstyles are available viadisplayNotificationbut aren't wired through the server SDK yet — their schemas need versioning (person avatars, reply actions) before the wire contract is frozen. - Expo Go not supported. Expo CNG / prebuild development builds are supported. Expo Go cannot load the native NotifyKit module, the generated iOS NSE target, or the embedded
.appex. - Firebase/RNFirebase setup is not owned by NotifyKit. The NotifyKit config plugin does not create Firebase apps, install RNFirebase, copy
google-services.json, configureGoogleService-Info.plist, or patch Gradle for Firebase. - Android data-only delivery is conditional. Background and killed-state data-only delivery depends on FCM priority, device state, Doze, and OEM policy. Use high priority for user-visible background validation, but do not treat delivery on every OEM as guaranteed.
- Android force-stop is excluded. FCM and Android do not guarantee delivery after
adb shell am force-stopor a user force-stop. Killed-state validation in 10.4.0 was best-effort only and did not include force-stop. - Android Expo tap validation scope. Android foreground/background delivery and tap routing were validated in the Expo smoke app on a Pixel 9 Pro XL, including
SMOKE:BACKGROUND_EVENT_PRESSand optionalSMOKE:FOREGROUND_EVENT_PRESS. This is not a guarantee for every device, OEM state, Doze state, or force-stop path. - iOS Expo validation scope. The 10.4.0 Expo gate verified runtime base behavior, foreground FCM delivery on a physical iPhone, logical
ios.attachmentsmetadata, and theNotifyKitNSEprocess. iOS visible FCM background delivery and tap-to-open were observed, but JSPRESSmarker validation for background tap remains a follow-up. It did not verify iOS killed state, visual lock-screen/banner attachment rendering, textual NSE log capture, or iOS RNFirebase data-only/client-handler behavior. - Android foreground service plugin scope. The plugin configures NotifyKit foreground service manifest requirements only when explicitly opted in. The 10.4.0 runtime gate validated
shortServiceon a Pixel 9 Pro XL, but other foreground service types were not runtime-validated. It does not add exact-alarm automation, full-screen intent automation, Firebase setup, Maven/Gradle repository workarounds, or Google Play policy approval. - RNFirebase modular API warning cleanup. The Expo smoke app was migrated to React Native Firebase modular APIs to remove the namespaced API deprecation warning from the smoke paths. This is smoke fixture cleanup only and does not change the public NotifyKit API or add a consumer requirement.
- The CLI creates the NSE target once. Re-running
init-nsewithout--forceis a no-op. Re-running with--forcewill overwriteNotificationService.swift— back it up first if you've customized the file.
Comparison with other libraries
| Feature | NotifyKit FCM Mode | Manual @notifee + RNFB | OneSignal | expo-notifications |
|---|---|---|---|---|
| Android display | displayNotification from headless task | Same | OneSignal SDK | expo-notifications |
| iOS background reliability | APNs alert (~99%) via NSE | Data-only silent (~40-70%) | APNs alert (proprietary) | APNs alert (Expo-managed) |
| iOS NSE setup | Expo config plugin or bare RN CLI | Manual Xcode steps | Bundled, closed-source | Managed (no NotifyKit NSE) |
| Backend SDK | react-native-notify-kit/server (zero deps) | Hand-rolled FCM v1 | OneSignal REST API | Expo push REST API |
| Backend runs on | Any Node.js (CFns, Lambda, self-hosted) | Any Node.js | OneSignal (vendor lock) | Expo servers (vendor lock-ish) |
| Notification styling | BIG_TEXT, BIG_PICTURE, actions, attachments, categories, thread-id | Full Notify Kit surface | OneSignal-specific | Limited (no custom styles on Android) |
| Rich iOS notifications | Yes, via NSE blob | Yes, via NSE blob | Yes | Limited |
| Foreground services | Via displayNotification | Same | Not supported | Not supported |
| Trigger notifications (scheduled) | Full Notify Kit (AlarmManager) | Same | OneSignal scheduling | Basic (local) |
| Source availability | Apache-2.0, open source | Same | Closed source | Apache-2.0, vendor-tied |
| Data-only pushes | Supported (fall through to displayNotification) | Supported | Supported | Limited |
| Works without FCM | No (FCM is the transport) | No | Proprietary transport | Expo transport |
| Monthly active device limit | FCM free (unlimited) | FCM free | Free tier cap, paid beyond | Free |
See the main README for Notify Kit's full feature surface, the server SDK README for a compact server reference, and the CHANGELOG for release history.
