1.119.2 Push Notification Libraries#


Explainer

Push Notification Libraries — Domain Explainer#

What Are Push Notifications?#

Push notifications are messages that originate on a server and are delivered to a device, even when the app is not running. They appear as banners, sounds, or badge counts. You’ve seen them: “You have 3 new messages,” “Your package is out for delivery,” “Score alert: 49ers 21 — Cowboys 7.”

The word “push” distinguishes this from “pull” (where the app checks the server periodically). Push notifications are server-initiated — the server decides to send; the client receives.


The Architecture#

Push notifications follow a specific path:

Your Server → Platform Gateway (APNs or FCM) → Device OS → Your App

You cannot send directly to a device. The device’s operating system controls a persistent connection to Apple’s APNs (for iOS) or Google’s FCM (for Android). Your server sends to the gateway; the gateway delivers to the device via this OS-maintained connection.

This design exists for battery and security reasons: one OS-level connection is more efficient than each app maintaining its own. The OS also controls which apps can receive background messages, preventing abuse.


The Two Gateways#

APNs: Apple Push Notification Service#

APNs is the gateway for every notification on iOS, iPadOS, macOS, watchOS, and tvOS. There is no alternative — Apple controls the path. To send to an iOS device, you must:

  1. Have an Apple Developer account
  2. Obtain a .p8 key file and use it to sign JWT tokens (the modern, recommended authentication method)
  3. Open an HTTP/2 connection to api.push.apple.com and POST a JSON payload to a device-specific endpoint

Device token: Each app installation gets a unique device token from APNs. Your app receives this token and sends it to your server. Your server uses this token to address push notifications to that specific device installation.

FCM: Firebase Cloud Messaging#

FCM is Google’s gateway for Android devices. It also acts as a relay to APNs — if you use FCM’s SDK on iOS, FCM handles the APNs delivery for you, at the cost of routing through Google’s servers.

FCM uses a registration token (analogous to APNs device token). For Android, FCM delivers directly. For iOS, FCM relays to APNs.

FCM v1 API: The current API requires OAuth 2.0 service account authentication. The old “legacy” API was removed in June 2024 — any server code using the old API is now broken.


What Lives in a Notification#

A push notification payload is a JSON document:

{
  "aps": {
    "alert": {
      "title": "New Message",
      "body": "Alice sent you a photo"
    },
    "badge": 3,
    "sound": "default"
  },
  "sender_id": "user-alice-123"
}

On iOS: The aps dictionary is standardized by Apple. The outer object can contain custom fields. aps.alert controls what the user sees. aps.badge sets the app icon badge count. aps.sound triggers sound.

On Android: Android 8+ requires notifications to be assigned to a channel — a category with user-configurable importance, sound, and vibration settings. Channels like “Messages” and “Promotions” let users mute marketing without disabling all notifications.


Silent Push: Background Processing#

Beyond visible notifications, push can trigger background processing without showing any UI.

  • iOS: Send content-available: 1 in the payload. The OS wakes the app for ~30 seconds to process data. This is rate-limited — iOS will throttle or skip notifications if the app receives too many.
  • Android: Send a “data-only” FCM message (no notification field, only data). The app’s onBackgroundMessage handler is called even when the app is killed.

Common use case: sync a small amount of data, update a cache, or prepare content so the app loads fast when opened.


User Permission#

iOS requires explicit opt-in. Your app displays a system permission prompt: “App would like to send you notifications. Allow?” Roughly 50–60% of iOS users grant permission in most app categories.

Android 13+ added the same requirement. Before Android 13, Android push was opt-out. Now it matches iOS’s opt-in model, and opt-in rates are converging toward iOS levels.

The lesson: Push reach is not 100%. Design for users who don’t opt in. Permission prompts shown with context (“Get alerts for messages”) convert better than prompts shown on first launch with no context.


Cross-Platform Libraries#

For mobile developers who don’t want to interact directly with APNs/FCM, libraries provide a unified API:

React Native:

  • @react-native-firebase/messaging — wraps FCM (and relays to APNs for iOS). Handles token registration, message receipt, topic subscriptions.
  • @notifee/react-native — handles the display layer: creating Android channels, showing notification banners, adding action buttons.

These two work together: Firebase delivers the message; Notifee displays it. Both maintained by Invertase.

Flutter:

  • firebase_messaging — the FlutterFire equivalent of react-native-firebase/messaging.
  • flutter_local_notifications — the display layer equivalent, handling Android channels, iOS categories, scheduling.

Expo: If using Expo’s managed workflow, @expo/expo-notifications plus the Expo Push Service provides a simpler setup. Expo manages APNs/FCM credentials; your server sends to Expo’s API.


Third-Party Services#

Several services sit above APNs/FCM and provide higher-level features:

OneSignal: The most widely used. Free tier for unlimited push. You register your APNs and FCM credentials with OneSignal; OneSignal handles token storage, routing, and delivery. Server API is simple: send to all users in segment "New Users". Built-in A/B testing, scheduling, analytics.

Novu: Open-source notification infrastructure. Handles push, email, SMS, and in-app notifications through a unified workflow DSL. Self-hostable. Growing adoption for teams building notification systems as a product feature.


Web Push#

Browsers can receive push notifications too, via the Web Push Protocol (RFC 8030) and VAPID authentication (RFC 8292). The flow:

  1. User visits your site and grants notification permission
  2. Browser generates a push subscription (unique endpoint URL + encryption keys)
  3. Your server sends encrypted push messages to that endpoint
  4. Browser OS delivers to the service worker
  5. Service worker displays the notification (even when browser is closed)

VAPID (Voluntary Application Server Identification) is the authentication mechanism. Generate a public/private key pair; the public key goes to the browser, the private key stays on your server. The browser uses the public key to verify that pushes come from your server.

Browser support (2026): Chrome, Firefox, Edge, Safari macOS 13+, Safari iOS 16.4+ (but iOS requires the site to be installed as a PWA).


Delivery Is Best-Effort#

Neither APNs nor FCM guarantees delivery. If the device is offline, the gateway stores one notification (the latest) for a configured TTL. If the device stays offline past the TTL, the notification is discarded silently — your server doesn’t know.

For applications requiring confirmed delivery, implement an application-level acknowledgment: when the app receives a notification, it sends a ping to your server. If no ping arrives within N minutes, resend or fall back to another channel (email, SMS).

This is the reason notification routing layers (Novu, Courier, Knock) are gaining traction — they manage multi-channel fallback logic, so a push that goes unacknowledged can automatically trigger an email fallback.

S1: Rapid Discovery

S1 Approach: Push Notification Libraries#

Research ID: 1.119.2 Pass: S1 — Rapid Discovery Date: 2026-02-17

Research Questions#

  1. What are the platform notification APIs (APNs, FCM) and how do they differ?
  2. What cross-platform libraries wrap APNs/FCM for React Native and Flutter?
  3. What server-side SDKs handle notification delivery?
  4. What third-party services (OneSignal, Expo, Novu) abstract the push stack?
  5. What is Web Push and how does it work (VAPID)?
  6. What are the key constraints: delivery guarantees, background restrictions, silent push?

Scope#

  • Mobile push: iOS (APNs) and Android (FCM)
  • Cross-platform wrappers: React Native and Flutter
  • Server-side delivery: firebase-admin, apns2, custom HTTP/2
  • Third-party services: OneSignal, Expo Push, Novu
  • Web Push: VAPID, service workers, browser support
  • In-app notification management (channels, categories, display)

Sources to Check#

  • APNs documentation: developer.apple.com/documentation/usernotifications
  • FCM documentation: firebase.google.com/docs/cloud-messaging
  • npm: react-native-push-notification, @notifee/react-native, @react-native-firebase/messaging
  • pub.dev: firebase_messaging, flutter_local_notifications
  • OneSignal, Expo Push API docs

S1 Overview: Push Notification Libraries#

What Push Notifications Are#

Push notifications deliver messages to a device even when the app is not running. Unlike in-app notifications, they originate on a server and are delivered through platform-managed infrastructure. The user sees a banner, badge, or sound; the app may process data in the background without opening.

The fundamental architecture:

Your Server → Push Gateway (APNs / FCM) → Device OS → App

Push is always asynchronous and best-effort — the gateway does not guarantee delivery or timing.


Platform Push Gateways#

APNs: Apple Push Notification Service#

APNs is Apple’s push gateway for iOS, iPadOS, macOS, watchOS, tvOS. All iOS push notifications must go through APNs — there is no alternative (no direct socket connection to devices).

Authentication methods:

  • Token-based (JWT): A .p8 key file + 10-digit Team ID + Key ID. The token is regenerated periodically (every hour). One key works for all apps in the team. This is the recommended method.
  • Certificate-based: A .p12 per-app certificate, renewed annually. Legacy; deprecated direction.

APNs HTTP/2 endpoint: https://api.push.apple.com (production), https://api.sandbox.push.apple.com (development).

Key payload fields:

{
  "aps": {
    "alert": {
      "title": "New Message",
      "body": "You have 3 unread messages"
    },
    "badge": 3,
    "sound": "default",
    "content-available": 1,
    "mutable-content": 1,
    "thread-id": "conversation-123",
    "category": "MESSAGE_CATEGORY"
  },
  "custom_data": "..."
}
  • content-available: 1 → silent push, triggers background processing
  • mutable-content: 1 → triggers Notification Service Extension (30-second processing window for image download, decryption, etc.)
  • Priority: apns-priority: 10 (immediate), apns-priority: 5 (power-saving, delayed)

iOS permissions: UNUserNotificationCenter.requestAuthorization(options:) — the user must grant permission for banners/sound/badge. Silent push (content-available) does not require user permission but is subject to system-level rate limiting.


FCM: Firebase Cloud Messaging#

FCM is Google’s push gateway for Android (and cross-platform via SDKs). FCM routes to Android devices directly; for iOS, FCM relays to APNs.

API versions:

  • Legacy HTTP API: Deprecated in June 2023, disabled June 2024. Use v1 API only.
  • FCM HTTP v1 API: Current standard. Authentication via OAuth 2.0 service account.

Endpoint: POST https://fcm.googleapis.com/v1/projects/{project_id}/messages:send

Message structure:

{
  "message": {
    "token": "device-registration-token",
    "notification": {
      "title": "Hello",
      "body": "You have a new message"
    },
    "data": {
      "key": "value"
    },
    "android": {
      "notification": {
        "channel_id": "messages",
        "priority": "high"
      }
    },
    "apns": {
      "payload": {
        "aps": { "content-available": 1 }
      }
    }
  }
}

Notification vs Data messages:

  • Notification message: Displayed by system automatically when app is in background. onMessageReceived called only in foreground.
  • Data message: Always delivered to onMessageReceived, even in background. Requires data field only, no notification field.

Android notification channels (required Android 8+, API 26): notifications must be assigned to a channel. Channels control sound, vibration, importance (min/low/default/high/max). Create channels in app startup.


Cross-Platform Libraries (React Native)#

@react-native-firebase/messaging#

The most popular FCM wrapper for React Native.

  • npm: @react-native-firebase/messaging
  • Downloads: ~370,000/week
  • Version: 22.x (early 2026)
  • Part of: React Native Firebase monorepo (by Invertase)
  • iOS: Routes through FCM → APNs (or direct APNs via firebase-admin)
  • Handles: foreground/background message handling, token management, topic subscriptions

@notifee/react-native#

Full notification display and management library — handles channels, styles, actions, scheduling.

  • npm: @notifee/react-native
  • Downloads: ~228,000/week
  • Version: 9.1.8
  • Maintainer: Invertase (same as RN Firebase)
  • Note: Works as a companion to @react-native-firebase/messaging. Firebase handles delivery; Notifee handles display.

react-native-push-notification#

Legacy library. Archived January 14, 2025.

  • npm: react-native-push-notification
  • Status: Archived (read-only). No new releases; security vulnerabilities unfixed.
  • Replacement: Use @react-native-firebase/messaging + @notifee/react-native

Expo Notifications (@expo/expo-notifications)#

Expo’s managed notification library for Expo apps.

  • Works with: Expo managed workflow and bare workflow
  • Version: 0.32.x (Expo SDK 52+)
  • Expo Push Service: Free cloud service that wraps APNs and FCM. Expo stores your push tokens and routes notifications. No server setup needed.
  • Limitation: If using Expo Push Service, the server sends to Expo’s API (https://exp.host/--/api/v2/push/send), not directly to APNs/FCM.

Cross-Platform Libraries (Flutter)#

firebase_messaging (FlutterFire)#

Maintained by the FlutterFire team (Google-supported community).

  • pub.dev: firebase_messaging
  • Version: 15.x (early 2026)
  • Pub points: 160/160
  • Likes: ~2,200
  • Downloads: Most-used Flutter push library

flutter_local_notifications#

Companion library for displaying and scheduling local notifications (used alongside firebase_messaging).

  • pub.dev: flutter_local_notifications
  • Version: 19.x
  • Pub points: 160/160
  • Likes: ~3,400 (one of the highest-liked Flutter plugins)
  • Note: Requires Flutter SDK 3.22+

awesome_notifications#

Alternative to the FlutterFire stack. Handles both push delivery (via FCM) and local notification display.

  • pub.dev: awesome_notifications
  • Version: 0.10.x
  • Likes: ~2,200
  • Advantage: More visual customization options, built-in rich media support
  • Critical: Incompatible with flutter_local_notifications — cannot use both

Third-Party Notification Services#

OneSignal#

Most popular cross-platform push notification service. Free tier: unlimited mobile push; web push limited to 10K subscribers.

  • SDKs: iOS, Android, React Native, Flutter, Web, Unity, .NET, Node.js, Python
  • Features: Segmentation, A/B testing, in-app messaging, templates, analytics
  • Architecture: You send to OneSignal’s API; they route to APNs/FCM
  • Self-hosted: Not available (SaaS only, but open-source SDK)
  • Pricing: Free tier includes core push; paid tiers add advanced analytics

Expo Push Service#

Free push delivery for Expo apps. No APNs/FCM credentials needed on the server.

  • Endpoint: https://exp.host/--/api/v2/push/send
  • Limitation: Token format is ExponentPushToken[xxxx] — Expo managed
  • Advantage: Zero server setup; handles APNs/FCM complexity

Novu#

Open-source notification infrastructure. Self-hostable.

  • GitHub: gitenberg/novu (formerly novuhq/novu)
  • Stars: ~34,000 GitHub stars
  • Features: Unified API for push, email, SMS, in-app; notification workflows; subscriber management
  • Self-hosted: Yes (Docker); also cloud-hosted option
  • Positioning: “Notification router” layer above individual channels

Web Push (VAPID)#

Web Push allows browser-based apps to receive push notifications via service workers.

Protocol: Web Push Protocol (IETF RFC 8030) + VAPID authentication (RFC 8292).

How it works:

  1. Browser requests push subscription (PushManager.subscribe())
  2. Browser generates a push endpoint URL + subscription keys
  3. App sends endpoint to server
  4. Server sends encrypted push message to endpoint using VAPID-signed request
  5. Browser OS delivers to service worker
  6. Service worker shows notification via self.registration.showNotification()

Server library: web-push npm package — handles VAPID key generation, payload encryption, sending.

Browser support:

  • Chrome 50+: Yes
  • Firefox 44+: Yes
  • Safari 16+ (macOS): Yes (Web Push added 2022)
  • Safari iOS 16.4+: Yes (added March 2023)
  • Edge 17+: Yes

iOS Safari Web Push requires iOS 16.4+ and the site must be a PWA added to the Home Screen.


Key Constraints Summary#

ConstraintPlatformDetail
Delivery guaranteeAPNs/FCMBest-effort; no guaranteed delivery
Silent push rate limitingiOSSystem limits content-available frequency; too many = not delivered
Background restrictionsiOSApp killed by user blocks all background delivery
Notification channelsAndroid 8+Required; must create before use
Permission requirediOSOpt-in; ~45-55% grant rate in typical apps
Permission requiredAndroid 13+ (API 33)POST_NOTIFICATIONS permission now required (was implicit before)
Legacy API deprecationFCMLegacy HTTP API removed June 2024; v1 API required
react-native-push-notificationReact NativeUnmaintained; do not use for new projects

S1 Recommendation: Push Notification Libraries#

Quick Decision Table#

ContextRecommendation
React Native, FCM/APNs@react-native-firebase/messaging + @notifee/react-native
Flutter, FCM/APNsfirebase_messaging + flutter_local_notifications
Expo managed workflow@expo/expo-notifications (uses Expo Push Service)
Server-side (Node.js)firebase-admin SDK
Cross-platform serviceOneSignal (free tier; simplest setup)
Open-source routing layerNovu (self-hostable)
Web Pushweb-push npm + service worker
iOS nativeUserNotifications framework (UNUserNotificationCenter)
Android nativeFCM SDK + NotificationManager (channels required Android 8+)

What to Avoid#

  • react-native-push-notification — unmaintained since 2023
  • FCM legacy API (/fcm/send endpoint) — removed June 2024
  • Certificate-based APNs auth — use token-based (.p8) instead

Biggest Gotchas#

  1. Android 8+ requires notification channels — crashes or silent failure without them
  2. Android 13+ requires POST_NOTIFICATIONS permission — same as iOS opt-in now
  3. FCM data-only vs notification messages — different foreground/background handling
  4. iOS silent push rate limiting — cannot use content-available as a reliable signal

S1 Synthesis: Push Notification Libraries#

Key Findings#

1. Two Gateways Govern Everything#

All mobile push flows through APNs (iOS) or FCM (Android). There is no way to reach devices directly. Every library and service is a layer on top of these gateways. Understanding APNs and FCM deeply eliminates confusion about why certain behaviors occur.

2. FCM Legacy API Is Gone — v1 API Only#

FCM’s legacy HTTP API was deprecated June 2023 and disabled June 2024. Any server code using https://fcm.googleapis.com/fcm/send with an API key is broken. The current endpoint is https://fcm.googleapis.com/v1/projects/{id}/messages:send authenticated with an OAuth 2.0 service account token. This is a common source of production breakage for developers who haven’t updated their server code.

3. React Native: Firebase + Notifee Stack#

For new React Native projects:

  • @react-native-firebase/messaging (~370K/week) for FCM delivery and token management
  • @notifee/react-native (~125K/week) for local notification display, Android channels, rich media
  • Both maintained by Invertase; they are designed to work together
  • react-native-push-notification is unmaintained; do not start new projects with it

4. Flutter: firebase_messaging + flutter_local_notifications#

The dominant Flutter stack:

  • firebase_messaging (v15.x, 160 pts, ~2,200 likes) for FCM delivery
  • flutter_local_notifications (v18.x, 160 pts, ~3,400 likes) for display control
  • awesome_notifications is an alternative if richer display customization is needed

5. Expo Push Service Is the Simplest Server-Side Path#

For Expo-based React Native apps, the Expo Push Service eliminates the need to manage APNs/FCM credentials on the server. Tradeoff: the push tokens are Expo-managed (ExponentPushToken[...]), creating lock-in. When moving off Expo, tokens must be re-registered with raw APNs/FCM.

6. Web Push Now Works on iOS#

Safari iOS 16.4+ (March 2023) added Web Push support, but only for Progressive Web Apps added to the Home Screen. This is a meaningful but narrow use case — most mobile users don’t add PWAs to their Home Screen. Native push remains far more effective for iOS.

7. Silent Push Is Rate-Limited on iOS#

content-available: 1 (silent push for background processing) is subject to strict iOS rate limiting. Apple’s documentation warns that if the device receives too many silent push notifications, the system will start throttling them. This is not a replacement for scheduled background tasks.

What S2 Should Investigate#

  • APNs payload size limit (4KB), FCM payload limits
  • Android notification channels in depth (importance, behavior on lock screen)
  • Notification Service Extension: what you can do in 30 seconds, APNS decrypt pattern
  • @notifee/react-native API: triggers, display options, Android channel creation
  • firebase_messaging Flutter: background message handler registration, topic subscriptions
  • OneSignal API: send request format, targeting (all devices, segments, external user IDs)
  • Novu: architecture and workflow DSL
  • VAPID key management: generating keys, storing subscription objects
S2: Comprehensive

S2 Approach: Push Notification Libraries#

Research ID: 1.119.2 Pass: S2 — Comprehensive Analysis Date: 2026-02-17

Focus Areas#

  1. APNs HTTP/2 API — complete payload specification, authentication flow, error responses
  2. FCM v1 API — request format, message targeting (token, topic, condition), platform-specific configs
  3. Android notification channels — deep dive: importance levels, behavior, channel groups
  4. @react-native-firebase/messaging — complete API: token, topics, handlers, background
  5. @notifee/react-native — notification display, Android channels, iOS categories, triggers
  6. firebase_messaging (Flutter) — full lifecycle: permission, token, handler registration
  7. Notification Service Extension — iOS pattern for image attachment and payload mutation
  8. Web Push / VAPID — complete flow with code examples
  9. Delivery architecture — what guarantees exist (or don’t)
  10. Security — token rotation, preventing spoofed notifications

S2 Deep Analysis: Push Notification Libraries#

Research ID: 1.119.2 Pass: S2 — Comprehensive Analysis Date: 2026-02-17


1. APNs HTTP/2 API#

The token-based method uses a private key (.p8 file) from Apple Developer account. A JWT token is signed with this key and included in every APNs request.

JWT header: { "alg": "ES256", "kid": "KEY_ID" }
JWT claims: { "iss": "TEAM_ID", "iat": 1234567890 }
Signed with: ES256 using the .p8 private key

The token is valid for up to 1 hour. Regenerate it before expiry. Unlike certificate-based auth, one .p8 key covers all apps in the team.

HTTP/2 Request#

POST /3/device/{device_token}
Host: api.push.apple.com
Authorization: Bearer {JWT_token}
apns-topic: com.example.myapp
apns-push-type: alert
apns-priority: 10
apns-expiration: 0
Content-Type: application/json

{
  "aps": {
    "alert": {
      "title": "Title text",
      "subtitle": "Subtitle",
      "body": "Body text"
    },
    "badge": 5,
    "sound": "default",
    "category": "NEW_MESSAGE",
    "thread-id": "conversation-42"
  }
}

APNs Headers#

HeaderValuesNotes
apns-push-typealert, background, voip, complication, fileprovider, mdm, liveactivityRequired in iOS 13+. background for silent push.
apns-priority10 (high), 5 (low), 1 (very low, iOS 15+)Must be 5 for background type
apns-expirationUnix timestamp or 00 = don’t expire (keep trying), number = give up after this time
apns-collapse-idString (≤64 bytes)Replace previous notifications with same ID
apns-topicBundle IDRequired. For VoIP: com.example.app.voip

Payload Limits#

  • Maximum payload size: 4 KB
  • For VoIP push: 5 KB

Response Codes#

StatusMeaning
200Success
400Bad request (check apns-push-type, missing fields)
403Wrong certificate / token auth error
404Device token invalid
410Device token is no longer active (unregistered) — delete from DB
429Too many requests (rate limit hit)
500Internal server error
503Shutdown — APNs unavailable

Critical: HTTP 410 means the user uninstalled the app (or revoked notifications). Remove the token from your database immediately.

iOS Permission Flow#

// Request permission
UNUserNotificationCenter.current().requestAuthorization(
    options: [.alert, .badge, .sound, .provisional]
) { granted, error in
    if granted {
        DispatchQueue.main.async {
            UIApplication.shared.registerForRemoteNotifications()
        }
    }
}

// Receive device token
func application(_ application: UIApplication,
                 didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
    let token = deviceToken.map { String(format: "%02x", $0) }.joined()
    // Send token to your server
}

Provisional authorization (options: [.provisional]): Delivers notifications quietly (no banner/sound/alert) without requiring user permission prompt. The user later decides to keep or disable. A lower-friction acquisition strategy.

Critical alerts: Requires Apple entitlement. Overrides Do Not Disturb and silent mode. Not available to general apps.


2. FCM HTTP v1 API#

Authentication#

# Get OAuth 2.0 access token from service account credentials
# Using Google Auth libraries (Node.js example)
const { GoogleAuth } = require('google-auth-library');
const auth = new GoogleAuth({
    credentials: serviceAccountJson,
    scopes: ['https://www.googleapis.com/auth/firebase.messaging']
});
const client = await auth.getClient();
const accessToken = (await client.getAccessToken()).token;

Send Message#

POST https://fcm.googleapis.com/v1/projects/{project_id}/messages:send
Authorization: Bearer {access_token}
Content-Type: application/json

{
  "message": {
    "token": "device_registration_token",
    "notification": {
      "title": "Hello",
      "body": "You have a new message"
    },
    "data": {
      "custom_key": "custom_value"
    },
    "android": {
      "priority": "high",
      "notification": {
        "channel_id": "messages",
        "click_action": "MAIN_ACTIVITY"
      }
    },
    "apns": {
      "headers": {
        "apns-priority": "10",
        "apns-push-type": "alert"
      },
      "payload": {
        "aps": {
          "sound": "default",
          "badge": 1
        }
      }
    }
  }
}

Targeting Options#

// Single device (token)
{ "token": "device_registration_token_here" }

// Topic (all subscribers)
{ "topic": "weather-alerts" }

// Condition (boolean expression over topics)
{ "condition": "'weather' in topics && 'news' in topics" }

// Multicast (up to 500 tokens — use BatchMessage for >500)
// Note: multicast uses different endpoint

Notification vs Data Messages#

// Notification message (system handles display when app in background)
{
  "message": {
    "token": "...",
    "notification": { "title": "Alert", "body": "..." }
  }
}

// Data-only message (always delivered to onMessageReceived, even background)
{
  "message": {
    "token": "...",
    "data": { "key": "value" }
    // No "notification" field
  }
}

Behavior table:

StateNotification msgData-only msg
App foregroundonMessage handleronMessage handler
App backgroundSystem displays notificationonBackgroundMessage handler
App killedSystem displays notificationonBackgroundMessage handler (FCM)

3. Android Notification Channels#

Android 8.0 (API 26) requires all notifications to be assigned to a channel. If no channel exists, the notification is silently dropped.

// Create channel (call at app startup, idempotent)
val channel = NotificationChannel(
    "messages",                     // Channel ID
    "Messages",                     // Display name
    NotificationManager.IMPORTANCE_HIGH
).apply {
    description = "Message notifications"
    enableLights(true)
    lightColor = Color.BLUE
    enableVibration(true)
    setShowBadge(true)
}
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(channel)

Importance Levels#

LevelValueBehavior
IMPORTANCE_NONE0Blocked
IMPORTANCE_MIN1No sound, no banner, status bar only
IMPORTANCE_LOW2No sound, shows in shade
IMPORTANCE_DEFAULT3Sound, shows in shade
IMPORTANCE_HIGH4Sound + banner (heads-up)
IMPORTANCE_MAX5Full-screen intent; use for alarms/calls

Critical: After the user sees the channel, they can change the importance in settings. Changing the importance in code after this point has no effect — the user’s preference persists.


4. @react-native-firebase/messaging#

Setup and Token#

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

// Request permission (iOS)
const authStatus = await messaging().requestPermission();

// Get device token (FCM token)
const token = await messaging().getToken();

// Token refresh listener
messaging().onTokenRefresh(newToken => {
    // Send newToken to your server to replace old one
});

Message Handlers#

// Foreground handler
const unsubscribe = messaging().onMessage(async remoteMessage => {
    console.log('Foreground message:', remoteMessage);
    // Display manually using @notifee/react-native
});

// Background + killed app handler (must be outside any component)
// Register at the top of index.js
messaging().setBackgroundMessageHandler(async remoteMessage => {
    console.log('Background message:', remoteMessage);
    // Cannot update UI; can update data stores
});

Topics#

// Subscribe
await messaging().subscribeToTopic('weather');

// Unsubscribe
await messaging().unsubscribeFromTopic('weather');

5. @notifee/react-native#

Notifee handles the display layer — creating Android channels, styling notifications, scheduling.

Android Channel Creation#

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

await notifee.createChannel({
    id: 'messages',
    name: 'Messages',
    importance: AndroidImportance.HIGH,
    sound: 'default',
    vibration: true,
});

Display a Notification#

// Display notification (triggered by incoming FCM message in foreground)
await notifee.displayNotification({
    title: 'New Message',
    body: 'You received a message',
    android: {
        channelId: 'messages',
        smallIcon: 'ic_notification',
        pressAction: { id: 'default' },
    },
    ios: {
        categoryId: 'NEW_MESSAGE',
        attachments: [{
            url: 'https://example.com/image.jpg'
        }]
    }
});

Notification Events#

notifee.onBackgroundEvent(async ({ type, detail }) => {
    const { notification, pressAction } = detail;
    if (type === EventType.ACTION_PRESS && pressAction.id === 'reply') {
        // Handle reply action
    }
});

6. firebase_messaging (Flutter)#

Initialization#

import 'package:firebase_messaging/firebase_messaging.dart';

// Background message handler — must be top-level function
@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
    await Firebase.initializeApp();
    print('Background message: ${message.messageId}');
}

// In main():
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);

// Request permission (iOS)
final settings = await FirebaseMessaging.instance.requestPermission(
    alert: true,
    badge: true,
    sound: true,
    provisional: false,
);

// Get token
final token = await FirebaseMessaging.instance.getToken();

// Foreground messages
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
    // Show notification using flutter_local_notifications
});

Topic Subscriptions#

await FirebaseMessaging.instance.subscribeToTopic('news');
await FirebaseMessaging.instance.unsubscribeFromTopic('news');

7. Notification Service Extension (iOS)#

A Notification Service Extension is an app extension that runs for up to 30 seconds when a push notification with mutable-content: 1 arrives. Use cases: downloading image attachments, decrypting end-to-end encrypted payloads, modifying display text.

// NotificationService.swift
class NotificationService: UNNotificationServiceExtension {

    var contentHandler: ((UNNotificationContent) -> Void)?
    var bestAttemptContent: UNMutableNotificationContent?

    override func didReceive(_ request: UNNotificationRequest,
                             withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
        self.contentHandler = contentHandler
        bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)

        if let content = bestAttemptContent {
            // Download image attachment
            if let imageURLString = request.content.userInfo["image_url"] as? String,
               let imageURL = URL(string: imageURLString) {
                downloadImage(from: imageURL) { localURL in
                    if let localURL = localURL {
                        let attachment = try? UNNotificationAttachment(
                            identifier: "image",
                            url: localURL,
                            options: [UNNotificationAttachmentOptionsTypeHintKey: "public.jpeg"]
                        )
                        if let attachment = attachment {
                            content.attachments = [attachment]
                        }
                    }
                    contentHandler(content)
                }
            } else {
                contentHandler(content)
            }
        }
    }

    override func serviceExtensionTimeWillExpire() {
        // Called 1 second before deadline — deliver best-attempt content
        if let contentHandler, let content = bestAttemptContent {
            contentHandler(content)
        }
    }
}

Payload requirements: APNs message must have mutable-content: 1 in the aps dictionary. If the extension crashes or takes too long, the original unmodified notification is shown.


8. Web Push / VAPID#

Flow#

  1. Generate VAPID keys (once, store on server):
const webpush = require('web-push');
const vapidKeys = webpush.generateVAPIDKeys();
// vapidKeys.publicKey, vapidKeys.privateKey
  1. Subscribe in browser:
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: vapidPublicKeyBase64
});
// Send subscription object to your server
  1. Send push from server:
webpush.setVapidDetails('mailto:[email protected]', publicKey, privateKey);

await webpush.sendNotification(
    subscriptionObject,  // from client
    JSON.stringify({
        title: 'Hello',
        body: 'Web push notification',
        icon: '/icon.png',
        badge: '/badge.png',
        url: '/open?id=123'
    })
);
  1. Service worker receives event:
// service-worker.js
self.addEventListener('push', event => {
    const data = event.data.json();
    event.waitUntil(
        self.registration.showNotification(data.title, {
            body: data.body,
            icon: data.icon,
            badge: data.badge,
            data: { url: data.url }
        })
    );
});

self.addEventListener('notificationclick', event => {
    event.notification.close();
    event.waitUntil(clients.openWindow(event.notification.data.url));
});

Browser Support (2026)#

BrowserSupportNotes
Chrome (Android + Desktop)Since Chrome 50 (2016)
FirefoxSince Firefox 44 (2016)
EdgeChromium-based
Safari macOS 13+Added in Safari 16 (2022)
Safari iOS 16.4+Added March 2023; requires PWA added to Home Screen
Samsung InternetChromium-based

iOS limitation: Web Push on iOS 16.4+ requires the web app to be installed as a PWA (Add to Home Screen). Opening in Safari’s regular browser mode will not receive push.


9. Server-Side Libraries#

firebase-admin (Node.js)#

const admin = require('firebase-admin');

admin.initializeApp({
    credential: admin.credential.cert(serviceAccountJson)
});

// Send to single device
await admin.messaging().send({
    token: deviceToken,
    notification: { title: 'Alert', body: 'Hello' },
    android: { priority: 'high' },
    apns: { payload: { aps: { sound: 'default' } } }
});

// Send to topic
await admin.messaging().send({
    topic: 'weather-alerts',
    notification: { title: 'Weather Alert', body: 'Storm incoming' }
});

// Multicast (up to 500 tokens)
await admin.messaging().sendEachForMulticast({
    tokens: tokenArray,
    notification: { title: 'News', body: 'Breaking: ...' }
});

apns2 (Direct APNs, Node.js)#

For sending directly to APNs without Firebase:

const { ApnsClient, Notification, SilentNotification } = require('apns2');

const client = new ApnsClient({
    team: 'TEAM_ID',
    keyId: 'KEY_ID',
    signingKey: fs.readFileSync('./key.p8'),
    defaultTopic: 'com.example.myapp',
    pingInterval: 5000,
    production: true
});

// Standard notification
const notification = new Notification(deviceToken, {
    aps: {
        alert: { title: 'Hello', body: 'World' },
        badge: 1,
        sound: 'default'
    }
});
await client.send(notification);

// Silent notification
const silent = new SilentNotification(deviceToken);
await client.send(silent);

10. Delivery Architecture and Guarantees#

Your Server
    ↓ (HTTP/2 over TLS)
APNs / FCM Gateway
    ↓ (carrier infrastructure)
Device Radio (LTE/WiFi)
    ↓
OS Push Service
    ↓
Your App

Delivery guarantees:

  • APNs: “Best effort delivery.” If the device is offline, APNs stores one notification per app for later delivery (the last notification wins; earlier ones are discarded). Expiration controlled by apns-expiration header.
  • FCM: Similar best-effort. Stores with TTL (default: 4 weeks). collapse_key allows later messages to replace earlier ones.
  • No receipts: Neither APNs nor FCM confirms device-level delivery. If you need receipt confirmation, implement an app-side acknowledgment ping back to your server.

Delivery failure signals:

  • APNs HTTP 410 → device token invalid (unregistered) → delete token
  • FCM registration-token-not-registered → same
  • FCM InvalidRegistration → token format error

Sources#


S2 Findings: Push Notification Libraries#

Research ID: 1.119.2 Pass: S2 — Comprehensive Analysis Date: 2026-02-17


1. APNs (Apple Push Notification Service)#

1.1 Authentication: Token-Based vs Certificate-Based#

APNs supports two authentication mechanisms for the provider API:

Token-based authentication (recommended)

Uses a .p8 private key file from the Apple Developer portal. For each HTTP/2 request, the server generates a JWT signed with the ES256 algorithm:

JWT header: { "alg": "ES256", "kid": "<KEY_ID>" }
JWT claims: { "iss": "<TEAM_ID>", "iat": <unix_timestamp> }

The JWT is valid for up to one hour. APNs returns HTTP 403 (ExpiredProviderToken) if the token is older than one hour. The key itself does not expire (unless revoked), and a single .p8 key works across all apps within the team and across both sandbox and production environments.

Certificate-based authentication

Uses a .p12 SSL certificate tied to a specific app bundle ID. Certificates expire annually and must be renewed. Each certificate is valid for only one app and one environment (sandbox or production). Certificate-based auth is still supported but not recommended for new implementations.

Why token-based is preferred: no annual renewal, one key for all team apps, smaller operational overhead, works in both environments simultaneously. Apple’s 2025 update introduced team-scoped and topic-specific keys, giving finer-grained control, but existing keys continue working without changes.

2025 APNs server certificate change: Apple changed the Certification Authority for APNs servers from the old DigiCert CA to USERTrust RSA CA (SHA-2). This applied to sandbox on January 20, 2025, and production on February 24, 2025. This affects TLS trust validation on the client side, not provider authentication. Servers using system trust stores were unaffected; servers pinning the old CA cert required updates.

1.2 APNs HTTP/2 Endpoint#

  • Sandbox: https://api.sandbox.push.apple.com/3/device/{device_token}
  • Production: https://api.push.apple.com/3/device/{device_token}

APNs requires HTTP/2. Each connection can have multiple concurrent streams. Apple recommends maintaining persistent connections rather than creating a new connection per notification. Connection setup involves TLS 1.2+ negotiation which takes time.

1.3 Request Headers#

HeaderRequiredValuesNotes
apns-push-typeYes (iOS 13+)alert, background, voip, complication, fileprovider, mdm, liveactivitybackground for silent push
apns-priorityNo10 (high/immediate), 5 (low), 1 (very low, iOS 15+)Must be 5 for background type; 10 for alerts
apns-expirationNoUnix timestamp or 00 = keep trying indefinitely; a timestamp = expire at that time
apns-collapse-idNoString, max 64 bytesReplaces any prior notification with the same ID on the device
apns-topicYesBundle ID (e.g., com.example.app)For VoIP: com.example.app.voip

1.4 APNs Payload Structure#

{
  "aps": {
    "alert": {
      "title": "New Message",
      "subtitle": "From Jane",
      "body": "Are you free tonight?",
      "title-loc-key": "MSG_TITLE",
      "loc-key": "MSG_BODY",
      "loc-args": ["Jane"]
    },
    "badge": 5,
    "sound": "default",
    "content-available": 1,
    "mutable-content": 1,
    "category": "NEW_MESSAGE",
    "thread-id": "conversation-42",
    "interruption-level": "time-sensitive"
  },
  "custom_key": "custom_value"
}

Key aps fields:

  • alert: Can be a string (body only) or a dictionary with title, subtitle, body, and localization keys. Using a dictionary is required for rich notifications.
  • badge: Integer for the app icon badge count. 0 clears the badge.
  • sound: "default" uses system sound. For critical alerts: {"critical": 1, "name": "alert.wav", "volume": 0.8}.
  • content-available: Set to 1 for silent background push. The app has ~30 seconds to process. Must use priority 5. Rate-limited by iOS (see section 1.7).
  • mutable-content: Set to 1 to invoke the Notification Service Extension. Requires an alert dictionary (not used with background-only payloads).
  • category: References a registered UNNotificationCategory for action buttons.
  • thread-id: Groups related notifications in Notification Center (and for watch complications).
  • interruption-level: iOS 15+. Values: passive, active, time-sensitive, critical. Controls Focus mode behavior.

Payload size limit: 4 KB for standard notifications, 5 KB for VoIP push.

Custom data fields are placed at the top level alongside aps, not inside it.

1.5 Priority Semantics#

  • Priority 10 (apns-priority: 10): Immediate delivery. Wakes device. Required for alerts.
  • Priority 5 (apns-priority: 5): Delivered when the device is active, not at fixed intervals. Does not wake a sleeping device. Required for background push type.
  • Priority 1 (apns-priority: 1): iOS 15+. Conserves power further; useful for Watch complications.

1.6 APNs Collapse ID#

The apns-collapse-id header allows a newer notification to replace an older undelivered one with the same collapse ID. APNs stores only the most recent notification with a given collapse ID for each device. Useful for live score updates, chat typing indicators, and any stream where only the latest value matters. Maximum 64 bytes.

1.7 Environments#

  • Sandbox: For development builds. Separate device tokens from production; a sandbox token is invalid on production and vice versa.
  • Production: For App Store and TestFlight builds.

Provisioning profiles determine which environment the app registers with. Token-based auth works on both environments using the same .p8 key.

1.8 UNUserNotificationCenter: Permissions#

UNUserNotificationCenter.current().requestAuthorization(
    options: [.alert, .badge, .sound, .provisional]
) { granted, error in
    if granted {
        DispatchQueue.main.async {
            UIApplication.shared.registerForRemoteNotifications()
        }
    }
}

Permission options:

  • .alert, .badge, .sound: Standard permissions. Triggers the system prompt on first request.
  • .provisional: Delivers notifications quietly (Notification Center only, no banner, no sound) without asking the user. The user is later prompted in Notification Center to keep quiet delivery or enable prominent delivery. Useful for onboarding flows where forcing a permission prompt causes high denial rates.
  • .criticalAlert: Overrides silent mode and Do Not Disturb. Requires Apple entitlement (com.apple.developer.usernotifications.critical-alerts). Not available to general apps — requires Apple approval for medical, safety, or public safety use cases.

Foreground delivery: By default, notifications do not display when the app is in the foreground. Implement UNUserNotificationCenterDelegate.willPresent(_:withCompletionHandler:) and call the handler with .banner, .sound, .badge to show them.

Background delivery: The app delegate’s application(_:didReceiveRemoteNotification:fetchCompletionHandler:) is called for both foreground and background. For silent push (content-available: 1), only background delivery is relevant.

1.9 Notification Service Extension#

The Notification Service Extension is an app extension (separate target in Xcode) invoked when:

  1. The notification has mutable-content: 1 in the aps dictionary
  2. The notification has an alert component (cannot be background-only)

The extension has up to 30 seconds (sometimes enforced at less than 30 seconds in practice) and a 24 MB memory limit. Common use cases:

  • Image attachments: Download an image URL embedded in the payload and attach it as a UNNotificationAttachment.
  • E2E decryption: Receive an encrypted payload, decrypt it in the extension, modify the display text before showing.
  • Analytics: Log notification delivery server-side without requiring the user to open the notification.
class NotificationService: UNNotificationServiceExtension {
    var contentHandler: ((UNNotificationContent) -> Void)?
    var bestAttemptContent: UNMutableNotificationContent?

    override func didReceive(_ request: UNNotificationRequest,
                             withContentHandler handler: @escaping (UNNotificationContent) -> Void) {
        self.contentHandler = handler
        bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent
        // Perform work (download, decrypt, etc.)
        // Call handler() when done
    }

    override func serviceExtensionTimeWillExpire() {
        // Called ~1 second before timeout; deliver best-attempt content
        if let handler = contentHandler, let content = bestAttemptContent {
            handler(content)
        }
    }
}

Pitfall: The extension runs in a separate process with no access to the main app’s Keychain or UserDefaults unless using an App Group. Shared container setup is required for token-based decryption.

Pitfall: Notification Service Extensions do not fire in the iOS Simulator when using simulated push (the “Send push” button in Instruments). Test on device.

1.10 Notification Content Extension#

A separate extension type (UNNotificationContentExtension) provides a custom UI rendered inside the notification when expanded. The extension is triggered by a category identifier. Content extensions can include buttons, maps, images, or any SwiftUI/UIKit view. No Notification Service Extension processing required.

1.11 Delivery Rate Limiting (Silent Push)#

For content-available: 1 (silent background push), iOS applies rate limiting. Apple’s documentation does not publish exact numbers but warns that sending too many silent notifications causes throttling. Practical guidance from field experience:

  • Sending more than 2-3 silent pushes per hour per device is risky; the system may begin dropping them.
  • Low Power Mode blocks all background app refresh, including silent push.
  • The device’s heuristics consider battery level, app usage patterns, and network state.
  • Dropped silent notifications are not reported back to the server. There is no failure signal.

Silent push is not a reliable real-time delivery mechanism. For critical background syncing, use BGAppRefreshTaskRequest (scheduled by the OS at its discretion) or silent push combined with robust client-side retry logic.

1.12 APNs HTTP/2 Response Codes#

CodeMeaningAction
200SuccessNone
400Bad requestCheck push type, missing/malformed fields
403Auth errorToken expired (regenerate JWT) or wrong key
404Invalid device token formatValidate token format
410Device token unregisteredDelete from database immediately
429Too many requestsBack off; you have hit APNs rate limit
500Internal server errorRetry with exponential backoff
503APNs unavailable (shutdown)Retry later

HTTP 410 is the critical signal: the device has uninstalled the app or the user has explicitly revoked push permission for the app. Continuing to send to a 410 token wastes resources and may cause APNs to rate-limit the sending server.


2. FCM (Firebase Cloud Messaging)#

2.1 v1 API vs Legacy API#

Legacy API (deprecated June 2023, disabled July 22, 2024):

  • Endpoint: https://fcm.googleapis.com/fcm/send
  • Authentication: Server Key string in Authorization: key=<server_key> header
  • Any server using this endpoint is now broken.

HTTP v1 API (current standard):

  • Endpoint: https://fcm.googleapis.com/v1/projects/{project_id}/messages:send
  • Authentication: OAuth 2.0 access token from a Google service account
  • Access tokens are short-lived (typically 1 hour). Rotate using the google-auth libraries.
  • Supports HTTP/1.1 and HTTP/2.

Security advantage of v1: short-lived tokens reduce exposure if credentials are compromised, unlike the static legacy server key.

2.2 OAuth 2.0 Authentication (Service Account)#

const { GoogleAuth } = require('google-auth-library');

const auth = new GoogleAuth({
    credentials: serviceAccountJson, // from Firebase Console > Project Settings > Service accounts
    scopes: ['https://www.googleapis.com/auth/firebase.messaging']
});

async function getAccessToken() {
    const client = await auth.getClient();
    const token = await client.getAccessToken();
    return token.token;
}

The service account JSON is downloaded from Firebase Console > Project Settings > Service accounts > Generate new private key. Never commit this file to version control.

2.3 FCM v1 Message Structure#

{
  "message": {
    "token": "device_fcm_registration_token",
    "notification": {
      "title": "Title text",
      "body": "Body text",
      "image": "https://example.com/image.jpg"
    },
    "data": {
      "custom_key": "custom_value",
      "deep_link": "/screen/detail/123"
    },
    "android": {
      "priority": "high",
      "ttl": "86400s",
      "notification": {
        "channel_id": "messages",
        "click_action": "OPEN_ACTIVITY",
        "icon": "ic_notification",
        "color": "#FF0000",
        "sound": "default",
        "tag": "collapse-key-value"
      }
    },
    "apns": {
      "headers": {
        "apns-priority": "10",
        "apns-push-type": "alert",
        "apns-collapse-id": "message-thread-42"
      },
      "payload": {
        "aps": {
          "sound": "default",
          "badge": 1,
          "mutable-content": 1
        }
      }
    },
    "webpush": {
      "headers": {
        "TTL": "86400",
        "Urgency": "high"
      },
      "notification": {
        "title": "Title text",
        "body": "Body text",
        "icon": "/icon.png",
        "badge": "/badge.png",
        "requireInteraction": true
      },
      "fcm_options": {
        "link": "https://example.com/page"
      }
    }
  }
}

The notification field at the top level applies to all platforms. Platform-specific overrides go inside android, apns, or webpush. Data at the top level applies to all platforms.

2.4 Targeting Options#

{ "token": "single_device_token" }

{ "topic": "weather-alerts" }

{ "condition": "'sports' in topics && 'news' in topics" }

For multicast (up to 500 tokens at once), use sendEachForMulticast in the Admin SDK. For larger batches, split into chunks of 500.

Topics support up to 2,000 subscriptions per app instance. Topic subscription rate is limited to 3,000 QPS per project. Topic delivery is optimized for throughput over latency — do not use topics for time-sensitive single-device notifications.

2.5 Notification vs Data-Only Messages#

App StateNotification messageData-only message
ForegroundonMessage listener (no auto-display)onMessage listener
BackgroundSystem displays; onNotificationOpenedApp on taponBackgroundMessage handler
Killed (Android)System displays; data in getInitialNotification()onBackgroundMessage runs (restricted on some OEMs)
Killed (iOS)System displaysNot delivered on iOS when app is killed

Key pitfall: Data-only messages are not delivered to killed iOS apps through FCM. If you need background processing on iOS, use APNs content-available: 1 (which FCM wraps via the apns config).

2.6 Android Notification Channels#

Android 8.0 (API level 26) made notification channels mandatory. A notification posted without a valid channel ID is silently dropped. Channels must be created before posting — the standard pattern is to create channels at app startup (creation is idempotent).

Importance levels:

ConstantIntUser experience
IMPORTANCE_NONE0Blocked; not shown
IMPORTANCE_MIN1Status bar only; no sound, no banner
IMPORTANCE_LOW2Notification shade only; no sound
IMPORTANCE_DEFAULT3Sound; shows in shade
IMPORTANCE_HIGH4Sound + heads-up banner
IMPORTANCE_MAX5Full-screen intent (calls, alarms)

Critical behavior: Once a channel is seen by the user, changing the importance level in code has no effect. The user’s preference in Settings overrides the app’s code. To change channel importance after release, create a new channel with a new ID. Inform users of the change.

FCM’s default channel uses IMPORTANCE_DEFAULT. To show heads-up notifications (banners that appear over other content), create a channel with IMPORTANCE_HIGH and reference it in the channel_id field of the FCM AndroidNotification config.

2.7 FCM Delivery Semantics#

FCM operates on a best-effort delivery model. It does not guarantee delivery or delivery timing. Specific behaviors:

  • If the device is offline, FCM stores the message with the message’s TTL (default: 4 weeks, max: 4 weeks). Only the most recent message per collapse_key is stored.
  • FCM may throttle delivery to conserve battery.
  • When a device is online, FCM typically delivers within seconds, but this is not contractually guaranteed.
  • There is no device-level delivery receipt from FCM. Delivery confirmation requires an app-side acknowledgment sent back to your server.

Failure signals from FCM v1 API responses:

ErrorMeaning
UNREGISTEREDToken is no longer valid; delete from database
INVALID_ARGUMENTMalformed request or token format error
QUOTA_EXCEEDEDSending rate exceeded for the device
UNAVAILABLEFCM service temporarily unavailable; retry with backoff
INTERNALFCM internal error; retry

2.8 FCM Topics and Device Groups#

Topics: A pub/sub model where devices subscribe to named topics. The server sends to a topic name, and FCM fans out to all subscribers. No server-side subscriber list management needed. Limitation: topic delivery is eventually consistent with no ordering guarantees.

Device groups (legacy feature): Allows grouping multiple registration tokens under a notification key. Managed via separate Group Management API. Device groups are an older pattern; topics are preferred for fan-out and FCM token management is preferred for direct targeting.


3. Server-Side SDKs for APNs and FCM#

3.1 firebase-admin (Node.js, Python, Java, Go)#

The Firebase Admin SDK is the standard for server-side FCM and APNs (via FCM) sending. Available in Node.js, Python, Java, Go, and C#.

// Node.js
const admin = require('firebase-admin');
admin.initializeApp({ credential: admin.credential.cert(serviceAccountJson) });

// Single device
await admin.messaging().send({
    token: deviceToken,
    notification: { title: 'Alert', body: 'Hello' },
    android: { priority: 'high' },
    apns: { payload: { aps: { sound: 'default' } } }
});

// Topic
await admin.messaging().send({
    topic: 'weather-alerts',
    notification: { title: 'Storm Warning', body: 'Severe weather expected' }
});

// Multicast (up to 500 tokens)
const response = await admin.messaging().sendEachForMulticast({
    tokens: tokenArray,
    notification: { title: 'News', body: 'Breaking: ...' }
});
// response.successCount, response.failureCount, response.responses[]

The Admin SDK handles OAuth 2.0 token refresh automatically. It wraps both FCM and (for iOS) the APNs gateway via FCM’s routing layer.

3.2 node-apn / @parse/node-apn#

node-apn (original, npm install node-apn): Last published version 3.0.0 was released over 5 years ago. The original repository (node-apn/node-apn) is abandoned. An issue in the repository explicitly states “DON’T USE THIS LIB ANYMORE.” Do not use for new projects.

@parse/node-apn: Actively maintained fork by the Parse community. Version 6.5.0, approximately 107,000 weekly downloads. Wraps the same HTTP/2 protocol but with ongoing maintenance. Installation: npm install @parse/node-apn.

3.3 apns2#

apns2 (npm install apns2): Modern Node.js client for APNs using native HTTP/2 and JWT token authentication. Maintained by AndrewBarba on GitHub. Latest version: 12.2.0.

const { ApnsClient, Notification, SilentNotification } = require('apns2');

const client = new ApnsClient({
    team: 'TEAM_ID',
    keyId: 'KEY_ID',
    signingKey: fs.readFileSync('./key.p8'),
    defaultTopic: 'com.example.myapp',
    production: true,
    pingInterval: 5000
});

// Standard alert
const notification = new Notification(deviceToken, {
    aps: {
        alert: { title: 'Hello', body: 'World' },
        badge: 1,
        sound: 'default'
    }
});
await client.send(notification);

// Silent push
const silent = new SilentNotification(deviceToken);
await client.send(silent);

apns2 maintains a persistent HTTP/2 connection with keep-alive, avoiding the overhead of establishing a new connection per notification. Suitable for direct APNs sending without going through Firebase.

3.4 Expo Push API (Server SDK)#

The Expo Push Service accepts ExponentPushToken[...] format tokens and handles routing to APNs and FCM transparently.

const { Expo } = require('expo-server-sdk');
const expo = new Expo();

const messages = [{
    to: 'ExponentPushToken[xxxxxx]',
    sound: 'default',
    title: 'Hello',
    body: 'World',
    data: { key: 'value' }
}];

// Chunk into batches of 100
const chunks = expo.chunkPushNotifications(messages);
for (const chunk of chunks) {
    const receipts = await expo.sendPushNotificationsAsync(chunk);
}

API endpoint: https://exp.host/--/api/v2/push/send Rate limit: 600 notifications/second per project. Max per request: 100 notifications, 4096 bytes per notification total payload. Free for all Expo apps (no paid tier required for push). The trade-off: tokens are Expo-scoped; migrating away from Expo requires clients to re-register with raw APNs/FCM tokens.


4. React Native Push Libraries#

4.1 react-native-push-notification (DEPRECATED)#

  • Version: 8.1.1 (last published 4+ years ago)
  • Repository status: Archived by the owner on January 14, 2025. Read-only; no issues, PRs, or maintenance.
  • Weekly downloads: ~76,000 (legacy usage in existing apps)
  • Verdict: Do not start new projects with this library. Existing projects using it should plan migration to @react-native-firebase/messaging + @notifee/react-native.

4.2 @react-native-firebase/messaging#

The primary FCM integration for React Native. Maintained by Invertase. Shares versioning with the broader @react-native-firebase suite.

  • Current version: 22.x (aligned with the monorepo version)
  • Weekly downloads: ~370,000 (as of early 2026)
  • Repository: invertase/react-native-firebase
import messaging from '@react-native-firebase/messaging';

// iOS permission request
const authStatus = await messaging().requestPermission();
const enabled = (authStatus === messaging.AuthorizationStatus.AUTHORIZED ||
                 authStatus === messaging.AuthorizationStatus.PROVISIONAL);

// Get FCM token
const token = await messaging().getToken();

// Listen for token refresh
const unsubscribeToken = messaging().onTokenRefresh(newToken => {
    // Update token on server
});

// Foreground message handler
const unsubscribeMsg = messaging().onMessage(async remoteMessage => {
    // App is in foreground; FCM does not auto-display
    // Use @notifee/react-native to display
});

// Background / quit state handler (register at module scope in index.js)
messaging().setBackgroundMessageHandler(async remoteMessage => {
    // Limited environment; update storage, no UI updates
});

// App opened from a notification (tapped)
messaging().onNotificationOpenedApp(remoteMessage => {
    // Navigate to relevant screen
});

// Check if app launched via notification tap (cold start)
const initialNotification = await messaging().getInitialNotification();

Background handler registration: setBackgroundMessageHandler must be called at module scope (outside any component or React lifecycle), ideally in index.js. Failure to do this causes missed background messages.

Topics:

await messaging().subscribeToTopic('breaking-news');
await messaging().unsubscribeFromTopic('breaking-news');

4.3 @notifee/react-native#

Notifee handles the local notification display layer. Maintained by Invertase alongside @react-native-firebase. The two libraries are designed to work together.

  • Current version: 9.1.8
  • Weekly downloads: ~228,000 (as of early 2026)
  • Repository: invertase/notifee

Notifee manages:

  • Android notification channel creation (required for Android 8+)
  • Rich notification display (images, progress bars, custom actions)
  • iOS categories and action buttons
  • Scheduled/triggered notifications
  • Notification event handling (press, dismiss, action button taps)
import notifee, { AndroidImportance, EventType } from '@notifee/react-native';

// Create Android channel (idempotent; call at app startup)
await notifee.createChannel({
    id: 'messages',
    name: 'Messages',
    importance: AndroidImportance.HIGH,
    sound: 'default',
    vibration: true,
    vibrationPattern: [300, 500],
});

// Display a notification (called from foreground FCM handler)
await notifee.displayNotification({
    title: 'New Message',
    body: 'You have 3 unread messages',
    android: {
        channelId: 'messages',
        smallIcon: 'ic_notification',
        color: '#2196F3',
        pressAction: { id: 'default' },
        actions: [
            { title: 'Reply', pressAction: { id: 'reply' }, input: true },
            { title: 'Mark read', pressAction: { id: 'mark-read' } }
        ]
    },
    ios: {
        categoryId: 'NEW_MESSAGE',
        attachments: [{ url: imageLocalPath }],
        foregroundPresentationOptions: {
            badge: true,
            sound: true,
            banner: true,
            list: true,
        }
    }
});

// Background event handler
notifee.onBackgroundEvent(async ({ type, detail }) => {
    if (type === EventType.ACTION_PRESS && detail.pressAction.id === 'reply') {
        const replyText = detail.input; // from inline reply
        // Send reply
    }
    if (type === EventType.DISMISSED) {
        // Analytics
    }
});

4.4 expo-notifications (@expo/expo-notifications)#

The Expo notifications package for managed and bare workflow React Native apps.

  • Package: expo-notifications on npm
  • Current version: 0.32.x (SDK 52/53 aligned)
  • Weekly downloads: ~880,000-1,030,000 (very high due to Expo’s large user base)

Push notifications in Expo Go: Removed from Expo Go on Android from SDK 53 onward. A development build (npx expo run:android) is required. Local notifications still work in Expo Go.

import * as Notifications from 'expo-notifications';
import * as Device from 'expo-device';
import Constants from 'expo-constants';

// Request permission
const { status } = await Notifications.requestPermissionsAsync();

// Get Expo push token (for Expo Push Service)
const token = await Notifications.getExpoPushTokenAsync({
    projectId: Constants.expoConfig?.extra?.eas?.projectId,
});

// Get native APNs/FCM token (for direct APNs/FCM sending)
const nativeToken = await Notifications.getDevicePushTokenAsync();

// Notification handler (foreground behavior)
Notifications.setNotificationHandler({
    handleNotification: async () => ({
        shouldShowAlert: true,
        shouldPlaySound: true,
        shouldSetBadge: false,
    }),
});

// Listeners
const foregroundSub = Notifications.addNotificationReceivedListener(notification => {
    console.log(notification);
});
const responseSub = Notifications.addNotificationResponseReceivedListener(response => {
    console.log(response); // user tapped
});

5. Flutter Push Libraries#

5.1 firebase_messaging (FlutterFire)#

The standard FCM integration for Flutter, maintained as part of the FlutterFire suite by Google/Firebase.

  • Current version: 15.x (FlutterFire packages aligned to Firebase platform versions)
  • pub.dev: 130-160 pub points (verified package), 2,200+ likes
  • Maintained by: Google/Firebase team
import 'package:firebase_messaging/firebase_messaging.dart';

// Background handler must be a top-level function
@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
    await Firebase.initializeApp();
    // Cannot update UI; limited to data operations
}

// In main():
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);

// Permission (iOS only; Android auto-granted pre-13, prompted 13+)
NotificationSettings settings = await FirebaseMessaging.instance.requestPermission(
    alert: true,
    badge: true,
    sound: true,
    provisional: false,
    announcement: false,
    criticalAlert: false,
    carPlay: false,
);

// Get FCM token
String? token = await FirebaseMessaging.instance.getToken();

// Token refresh
FirebaseMessaging.instance.onTokenRefresh.listen((newToken) {
    // Update on server
});

// Foreground messages
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
    // System does not show notification automatically when app is foreground
    // Use flutter_local_notifications to display
});

// App opened from notification (background state tap)
FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) {
    // Navigate
});

// Cold start check
RemoteMessage? initialMessage =
    await FirebaseMessaging.instance.getInitialMessage();

// Topics
await FirebaseMessaging.instance.subscribeToTopic('news');
await FirebaseMessaging.instance.unsubscribeFromTopic('news');

Android 13+ permission: Android 13 (API 33) added a runtime notification permission (POST_NOTIFICATIONS). firebase_messaging handles requesting this permission automatically when requestPermission() is called.

5.2 flutter_local_notifications#

Handles local notification display on Android, iOS, macOS, Linux, and Windows. Commonly paired with firebase_messaging to display notifications when the app is in the foreground (since FCM does not auto-display in foreground on Flutter).

  • Current version: 19.x (19.1.0 as of early 2026)
  • pub.dev: ~160 pub points, 3,400+ likes
  • GitHub: MaikuB/flutter_local_notifications
import 'package:flutter_local_notifications/flutter_local_notifications.dart';

final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
    FlutterLocalNotificationsPlugin();

// Initialization
const AndroidInitializationSettings initializationSettingsAndroid =
    AndroidInitializationSettings('@mipmap/ic_launcher');

final DarwinInitializationSettings initializationSettingsIOS =
    DarwinInitializationSettings(
    requestSoundPermission: false, // Managed by firebase_messaging
    requestBadgePermission: false,
    requestAlertPermission: false,
);

final InitializationSettings initializationSettings = InitializationSettings(
    android: initializationSettingsAndroid,
    iOS: initializationSettingsIOS,
);

await flutterLocalNotificationsPlugin.initialize(
    initializationSettings,
    onDidReceiveNotificationResponse: (NotificationResponse response) {
        // Handle tap
    }
);

// Show notification
const AndroidNotificationDetails androidDetails = AndroidNotificationDetails(
    'messages',
    'Messages',
    channelDescription: 'Message notifications',
    importance: Importance.high,
    priority: Priority.high,
);
const NotificationDetails details = NotificationDetails(android: androidDetails);
await flutterLocalNotificationsPlugin.show(0, 'Title', 'Body', details);

Requires Flutter SDK 3.22+.

5.3 awesome_notifications#

An alternative to the FlutterFire stack for notifications, providing richer display customization.

  • Current version: 0.10.x (as of early 2026)
  • pub.dev: ~120 pub points, 2,200+ likes
  • Note: Incompatible with flutter_local_notifications — must choose one or the other

Key differences from flutter_local_notifications:

  • Supports scheduled, periodic, and alarm-based triggers
  • Built-in action button support
  • Progress indicators, big picture, inbox, and media-style notifications
  • Requires awesome_notifications_fcm add-on plugin for Firebase integration (the built-in Firebase support was deprecated)
import 'package:awesome_notifications/awesome_notifications.dart';

await AwesomeNotifications().initialize(
    'resource://drawable/ic_launcher',
    [
        NotificationChannel(
            channelKey: 'messages',
            channelName: 'Messages',
            channelDescription: 'Message notifications',
            defaultColor: Color(0xFF9D50DD),
            importance: NotificationImportance.High,
            channelShowBadge: true,
        )
    ]
);

await AwesomeNotifications().createNotification(
    content: NotificationContent(
        id: 1,
        channelKey: 'messages',
        title: 'New Message',
        body: 'You have a new message',
        notificationLayout: NotificationLayout.BigPicture,
        bigPicture: 'https://example.com/image.jpg',
    ),
    actionButtons: [
        NotificationActionButton(key: 'REPLY', label: 'Reply'),
        NotificationActionButton(key: 'MARK_READ', label: 'Mark Read'),
    ]
);

6. OneSignal and Cross-Platform Services#

6.1 OneSignal#

OneSignal is a notification delivery cloud service that manages APNs and FCM credentials on your behalf and provides a unified API.

Deployment models:

  • Cloud-hosted: OneSignal manages push credential provisioning, batching, and delivery routing. Simplest integration.
  • Self-hosted: Not available; OneSignal is a SaaS-only product. (Confusingly, some docs discuss “self-managed” meaning the developer manages their own APNs/FCM credentials, not server hosting.)

Pricing (2025-2026):

  • Free tier: Unlimited mobile push. Web push limited to 10,000 subscribers on free tier. 6 audience segments.
  • Growth plan: ~$19/month for expanded segments, A/B testing.
  • Professional/Enterprise: Additional journey steps, higher email volume, SOC 2 compliance, SLA guarantees.

SDKs: React Native (react-native-onesignal), Flutter (onesignal-flutter), iOS (Swift/Obj-C), Android, Web (JavaScript).

Targeting: OneSignal supports sending to:

  • All devices
  • Segments (filter by location, language, app version, custom tags)
  • External User IDs (map to your user database)
  • Individual players (device-specific)
  • Topics/groups

6.2 Expo Push Service#

Expo provides a managed push delivery service wrapping APNs and FCM.

  • Endpoint: https://exp.host/--/api/v2/push/send
  • Token format: ExponentPushToken[xxxx]
  • Cost: Free for all Expo apps
  • Limits: 600 notifications/second, 100 per batch request

Use the expo-server-sdk npm package for server-side sending. The Expo service handles APNs/FCM credential management, including automatic token invalidation detection.

Migration caveat: Expo push tokens are tied to Expo’s infrastructure. When migrating an app off of Expo (or to a bare workflow with direct APNs/FCM), all registered Expo tokens become invalid. Clients must re-register with native token registration.

6.3 Novu (Open Source Notification Routing)#

Novu is an open-source notification infrastructure platform. It abstracts multiple notification channels (push, email, SMS, in-app, chat) behind a unified workflow engine.

  • GitHub: novuhq/novu — 38,000+ stars
  • Self-hostable: Yes, via Docker
  • Channels: Email (SendGrid, SES, etc.), SMS (Twilio), Push (FCM/APNs via adapters), In-App inbox, Slack, Discord
  • Workflow: Define notification workflows in code or a visual editor; Novu handles routing, batching, and preference management

Novu does not deliver directly to APNs/FCM; it integrates with provider adapters (firebase-admin for FCM, etc.) and orchestrates delivery.

6.4 Knock#

Knock is a commercial notification infrastructure service (not open source). Features:

  • Multi-channel: push, email, SMS, in-app inbox, Slack, Discord
  • Workflow engine with batching, delay, and branching
  • User preference management
  • Does not deliver directly to APNs/FCM — integrates third-party provider SDKs

6.5 Courier#

Courier is a commercial multi-channel notification platform integrating 50+ providers. Similar to Knock but with broader marketing notification support alongside product notifications.

When to choose each:

NeedTool
Simple push only, low trafficDirect APNs/FCM via firebase-admin or apns2
Expo appsExpo Push Service
Managed push with segmentationOneSignal
Multi-channel + self-hostedNovu
Multi-channel + managed + enterpriseCourier or Knock

7. Web Push (VAPID)#

7.1 Standards#

Web Push is governed by two RFCs:

  • RFC 8030: “Generic Event Delivery Using HTTP Push” — defines the push subscription protocol and the endpoint contract between browsers (push service) and application servers.
  • RFC 8292: “Voluntary Application Server Identification (VAPID) for Web Push” — defines the JWT-based authentication scheme for application servers communicating with the browser’s push service.

Web Push uses the browser’s built-in push service (Google’s FCM for Chrome, Mozilla’s autopush for Firefox, Apple’s WebPush for Safari) as an intermediary. VAPID tokens allow the push service to attribute requests to specific application servers without requiring an account.

7.2 Browser Push Permission Flow#

  1. Page calls navigator.permissions.query({ name: 'push' }) (optional, to check current state)
  2. Page calls registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: vapidPublicKey })
  3. Browser shows the native permission prompt (“Allow notifications?”)
  4. On grant, a PushSubscription object is returned containing:
    • endpoint (browser-specific HTTPS URL)
    • keys.p256dh (client public key for encryption)
    • keys.auth (authentication secret)
  5. Page POSTs the subscription object to the application server for storage.

userVisibleOnly: true is required in Chrome. Setting it to false would allow silent background push without showing a notification, which Chrome blocks (always requires visible notification display).

7.3 web-push npm Library (Server-Side)#

const webpush = require('web-push');

// Generate VAPID keys (once; store securely)
const vapidKeys = webpush.generateVAPIDKeys();
// { publicKey: 'BASE64_URL...', privateKey: 'BASE64_URL...' }

// Configure (call at startup)
webpush.setVapidDetails(
    'mailto:[email protected]',  // Contact info for push service operators
    vapidKeys.publicKey,
    vapidKeys.privateKey
);

// Send push notification
await webpush.sendNotification(
    subscriptionObject,  // { endpoint, keys: { p256dh, auth } }
    JSON.stringify({
        title: 'Alert',
        body: 'New content available',
        icon: '/icon-192.png',
        badge: '/badge.png',
        url: '/news/article-123'
    })
);

Error handling:

  • HTTP 410 (Gone) from the push service means the subscription is expired/unsubscribed. Delete from database.
  • HTTP 429 (Too Many Requests) means rate-limited by the push service.

7.4 Service Worker Push Handler#

// service-worker.js

self.addEventListener('push', event => {
    let data = {};
    if (event.data) {
        data = event.data.json();
    }
    event.waitUntil(
        self.registration.showNotification(data.title || 'Notification', {
            body: data.body || '',
            icon: data.icon || '/icon-192.png',
            badge: data.badge || '/badge.png',
            data: { url: data.url || '/' },
            actions: [
                { action: 'open', title: 'Open' },
                { action: 'dismiss', title: 'Dismiss' }
            ],
            requireInteraction: false,
            tag: data.tag  // collapse-id equivalent for web push
        })
    );
});

self.addEventListener('notificationclick', event => {
    event.notification.close();
    const url = event.notification.data.url;
    event.waitUntil(
        clients.matchAll({ type: 'window' }).then(clientList => {
            // Focus existing window if open
            for (const client of clientList) {
                if (client.url === url && 'focus' in client) {
                    return client.focus();
                }
            }
            // Otherwise open new window
            if (clients.openWindow) return clients.openWindow(url);
        })
    );
});

self.addEventListener('notificationclose', event => {
    // Analytics: notification dismissed without tap
});

7.5 Browser Support (as of February 2026)#

PlatformSupportNotes
Chrome (Android)FullSince Chrome 50 (2016)
Chrome (Desktop)FullSince Chrome 50 (2016)
Firefox (Desktop + Android)FullSince Firefox 44 (2016)
EdgeFullChromium-based; same as Chrome
Samsung InternetFullChromium-based
Safari macOS 13+FullAdded in Safari 16 (2022)
Safari iOS 16.4+PartialAdded March 2023; requires PWA added to Home Screen
OperaFullChromium-based

Safari on iOS 16.4+ restriction: Web Push only works for Progressive Web Apps that the user has explicitly added to their Home Screen via “Add to Home Screen” in Safari. A website opened in Safari’s regular browser mode (not as a PWA) cannot receive Web Push. This significantly limits Web Push’s utility for iOS mobile compared to native push via APNs.

Safari macOS 12 and below does not support Web Push. It used a proprietary Safari Push Notification system (requiring an Apple Developer account, website push certificates, and Apple’s Safari Push Notification service) which is separate from the standard Web Push protocol.

7.6 VAPID Key Management#

  • Generate keys once; rotate only if the private key is compromised.
  • Store the private key server-side only, never expose it to clients.
  • The public key is embedded in the client-side subscribe call.
  • If you rotate VAPID keys, all existing push subscriptions are invalidated — users must re-subscribe. Plan key rotation carefully.

8. Silent Push / Background Fetch#

8.1 iOS: content-available:1#

Silent push on iOS works as follows:

  1. Server sends APNs notification with content-available: 1 in the aps dictionary, no alert, and apns-priority: 5.
  2. APNs delivers to the device without waking the display.
  3. iOS wakes the app for approximately 30 seconds.
  4. App performs work and calls the completion handler.

Restrictions and behavior:

  • Rate limiting: Apple does not publish exact limits. Practical experience indicates delivering more than 2-3 silent pushes per hour per device risks system throttling. At extreme rates (many per hour), the system may silently drop all of them.
  • Low Power Mode: Blocks all background app refresh when enabled. Silent push notifications are not delivered.
  • No delivery confirmation: There is no signal when a silent push is dropped. The server cannot distinguish between “delivered and processed” and “dropped by iOS.”
  • Priority 5 required: Using priority 10 with background type causes APNs to reject the message.
  • apns-push-type: background required: iOS 13+ requires apns-push-type: background in the header for silent push. Missing this header causes delivery failure on iOS 13+.
  • Entitlement: Apps must have the Background Modes capability with “Remote notifications” enabled in Xcode.

Use cases: Incremental data sync, refreshing content before the user opens the app, invalidating local caches.

8.2 Android: Data-Only FCM Messages#

Android’s equivalent of silent push is a data-only FCM message (no notification field in the FCM payload). Behavior:

  • The app’s FirebaseMessagingService.onMessageReceived() is called in the background.
  • No system notification is shown.
  • The app can process the data, update Room database, schedule a WorkManager task, etc.
  • OEM restriction: Some Android manufacturers (Xiaomi, Huawei, Samsung with aggressive battery management) may kill the app before it can process the message. This is not an FCM limitation but an OEM-specific battery optimization behavior.
  • When the app is in the killed state on Android, FCM data-only messages can still wake it (unlike iOS, where killed apps do not receive data-only FCM messages).

8.3 iOS 13+ BGAppRefreshTask#

BGAppRefreshTask is the OS-scheduled background execution API introduced in iOS 13. It does not rely on push at all.

import BackgroundTasks

// Register at launch (before application(_:didFinishLaunchingWithOptions:) returns)
BGTaskScheduler.shared.register(
    forTaskWithIdentifier: "com.example.app.refresh",
    using: nil
) { task in
    handleAppRefresh(task: task as! BGAppRefreshTask)
}

// Schedule a refresh
func scheduleAppRefresh() {
    let request = BGAppRefreshTaskRequest(identifier: "com.example.app.refresh")
    request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) // 15 minutes minimum
    try? BGTaskScheduler.shared.submit(request)
}

// Handle the task
func handleAppRefresh(task: BGAppRefreshTask) {
    scheduleAppRefresh() // Re-schedule for next time

    let operation = RefreshOperation()
    task.expirationHandler = {
        operation.cancel()
    }
    operation.completionBlock = {
        task.setTaskCompleted(success: !operation.isCancelled)
    }
    OperationQueue().addOperation(operation)
}

Important: Adding BGTaskSchedulerPermittedIdentifiers to Info.plist disables the legacy application(_:performFetchWithCompletionHandler:) method. The two systems cannot coexist.

BGAppRefreshTask is OS-scheduled based on usage patterns, charging state, and connectivity. It is not real-time; the OS determines when to grant execution time. A typical pattern is to use silent push to trigger an immediate refresh and BGAppRefreshTask for periodic non-urgent syncing.

8.4 Comparison: Silent Push vs BGAppRefreshTask vs Polling#

MechanismTimingReliabilityiOSAndroid
Silent Push (content-available)Near real-time triggerLow (throttled, dropped)YesYes (via FCM data-only)
BGAppRefreshTaskOS-determinedMedium (runs eventually)iOS 13+N/A (use WorkManager)
WorkManager (Android)Scheduled, OS-managedHighN/AAndroid 6+
Foreground pollingConstant (while active)HighYesYes

9. Library Versions and Download Statistics (February 2026)#

React Native#

LibraryVersionWeekly DownloadsStatus
@react-native-firebase/messaging22.x~370,000Active, maintained by Invertase
@notifee/react-native9.1.8~228,000Active, maintained by Invertase
expo-notifications0.32.x~880,000-1,030,000Active, maintained by Expo
react-native-push-notification8.1.1~76,000Archived Jan 14, 2025
react-native-notifications (Wix)5.2.2~26,000Active

Flutter (pub.dev)#

LibraryVersionPub PointsLikesStatus
firebase_messaging15.x~1602,200+Active (Google/Firebase)
flutter_local_notifications19.x~1603,400+Active (community)
awesome_notifications0.10.x~1202,200+Active (community)

Server-Side (Node.js npm)#

LibraryVersionWeekly DownloadsNotes
firebase-admin13.x~2,500,000Includes FCM, maintained by Google
@parse/node-apn6.5.0~107,000Active fork of node-apn
apns212.2.0ModerateActive, modern HTTP/2 APNs client
node-apn3.0.0(Legacy)Unmaintained; do not use
web-push3.6.xModerateVAPID/Web Push server library
expo-server-sdk3.xModerateFor sending via Expo Push API

10. Common Developer Pitfalls#

APNs:

  • Using priority 10 with apns-push-type: background — APNs rejects this combination. Background push must use priority 5.
  • Not handling HTTP 410 responses — leads to accumulating invalid tokens and wastes resources.
  • Closing the HTTP/2 connection between every notification — expensive. Keep the connection alive.
  • Forgetting apns-push-type header for iOS 13+ — silent push is silently dropped.
  • Ignoring the Notification Service Extension memory limit (24 MB) — extension is killed by the OS, showing the unmodified original notification.

FCM:

  • Still using the legacy API endpoint (fcm.googleapis.com/fcm/send) — it has been disabled since July 2024.
  • Not creating Android notification channels before posting — notification is silently dropped on Android 8+.
  • Expecting data-only FCM to reliably wake killed iOS apps — it does not. Use APNs content-available instead.
  • Putting sensitive data in the data payload without encryption — FCM data payloads are not end-to-end encrypted.

React Native:

  • Calling setBackgroundMessageHandler inside a component — it must be at module scope in index.js.
  • Starting new projects with react-native-push-notification — it was archived in January 2025.
  • Not creating Android channels when using @notifee/react-native — required for Android 8+.

Flutter:

  • Not decorating the background message handler with @pragma('vm:entry-point') — it gets tree-shaken in release builds.
  • Not calling Firebase.initializeApp() in the background handler — FCM fails in background isolate.
  • Using awesome_notifications alongside flutter_local_notifications — they conflict; choose one.

Web Push:

  • Expecting Web Push to work on iOS without Home Screen installation — Safari iOS requires PWA installation.
  • Not handling 410 (Gone) from the push service — expired subscriptions must be deleted.
  • Rotating VAPID keys without re-subscribing clients — all subscriptions become invalid.
  • Setting userVisibleOnly: false — Chrome requires true; setting false causes subscription failure.

Silent Push:

  • Relying on silent push for real-time data delivery — iOS rate limits and drops silent push heavily.
  • Not handling the completion handler quickly on iOS — exceeding the time window causes background time to be reduced for future requests.

Sources#


S2 Recommendation: Push Notification Libraries#

Decision Table#

NeediOSAndroidReact NativeFlutter
Basic push deliveryAPNsFCM@react-native-firebase/messagingfirebase_messaging
Notification display + channelsUNUserNotificationCenterNotificationManager + channels@notifee/react-nativeflutter_local_notifications
Background processingcontent-available + UNNotificationServiceExtensionData message + onBackgroundMessagesetBackgroundMessageHandleronBackgroundMessage
Topics/multicastAPNs via FCM relayFCM topicssubscribeToTopic()subscribeToTopic()
Managed service (no server)Expo Push / OneSignalSame@expo/expo-notifications or OneSignal SDKOneSignal SDK

Definitive Rankings#

React Native: @react-native-firebase/messaging for delivery + @notifee/react-native for display. Both by Invertase; designed as companion libraries. Weekly downloads ~370K and ~125K respectively.

Flutter: firebase_messaging (v15.x, FlutterFire) for delivery + flutter_local_notifications (v18.x) for display. Both at 160/160 pub points.

Server-side: firebase-admin SDK (Node.js/Python/Java) for FCM+APNs relay. apns2 for direct APNs without Firebase.

Managed service: OneSignal (free tier, simplest setup) or Expo Push Service (for Expo apps only).

Definitive Avoidance List#

  • react-native-push-notification — unmaintained; security vulnerabilities
  • FCM legacy /fcm/send API — removed June 2024
  • APNs certificate-based auth — deprecated direction; use .p8 token
  • Web Push as iOS notification strategy — requires PWA install, minimal real-world reach

S2 Synthesis: Push Notification Libraries#

What S2 Confirmed and Added#

1. FCM v1 API Migration Is a Production Blocker#

FCM’s legacy HTTP API (https://fcm.googleapis.com/fcm/send with server key) was removed in June 2024. Any codebase not yet migrated to the v1 API with OAuth 2.0 service account authentication is currently broken. The v1 API requires a service account JSON file and OAuth token generation — a more complex setup but proper cloud authentication.

2. Android Notification Channels Are Immutable After User Interaction#

Creating a channel is idempotent. However, once the user has seen the channel in Settings, the importance can no longer be changed programmatically. NotificationChannel.setImportance() after the channel exists has no effect. Practical implication: design channel importance levels thoughtfully at launch; you cannot increase importance after users have downgraded them. Consider offering separate channels for different notification types (messages vs. marketing) so users can control granularity.

3. Token Management Is a Continuous Process#

Push tokens are not permanent. A new token is issued when:

  • App reinstall
  • App data cleared
  • Device restore from backup
  • OS update (sometimes)
  • Token rotation by FCM/APNs (periodic)

Server code must: (1) always update token on login, (2) listen for onTokenRefresh, (3) handle 410 (APNs) and NotRegistered (FCM) by deleting stale tokens, (4) never assume a stored token is still valid.

4. Background Handling Asymmetry (Data vs Notification Messages)#

FCM data-only messages always invoke onBackgroundMessage even when the app is killed. FCM notification messages (containing notification field) are displayed by the OS in background/killed states without invoking any app code. This is a common source of confusion: developers add notification fields and then wonder why their background handler doesn’t run. For custom background logic: use data-only messages.

5. Notification Service Extension Is the Only Way to Add Image Attachments on iOS#

APNs does not support attaching images in the payload. The Notification Service Extension pattern (send a URL in the payload, download it in the extension within 30 seconds) is the standard approach. The 30-second limit is strict: serviceExtensionTimeWillExpire() is called ~1 second before deadline, at which point the original unmodified notification must be delivered. Extensions that consistently timeout will degrade user experience.

6. Web Push iOS Requires PWA Installation#

Safari iOS 16.4+ Web Push works only for apps installed as PWAs via “Add to Home Screen.” Regular browser sessions cannot receive push notifications. This dramatically limits real-world penetration — most users don’t install PWAs. For iOS push coverage, native APNs is required.

7. OneSignal Hides All Gateway Complexity#

OneSignal’s SDK handles token registration, APNs/FCM credential management, and delivery routing. The server-side API is simple (send to all, send to segment, send to external user ID). Tradeoffs: vendor lock-in, limited message customization vs raw APNs/FCM, and OneSignal’s servers are in the delivery path (latency, privacy).

S3: Need-Driven

S3 Approach: Push Notification Libraries#

Research ID: 1.119.2 Pass: S3 — Need-Driven Discovery Date: 2026-02-17

Personas and Use Cases#

  1. Mobile app developer: Basic push notifications for user engagement (messages, alerts)
  2. Chat app developer: High-priority notifications with reply actions, background token sync
  3. E-commerce developer: Promotional push with segmentation and A/B testing
  4. Backend engineer: Server-side push delivery with reliability and token management
  5. Web developer: Browser push notifications for PWA or web app
  6. Platform architect: Multi-channel notification routing (push, email, SMS in unified pipeline)

S3 Library Comparison: Push Notification Libraries#

React Native Libraries#

LibraryRoleDownloads/WeekVersionStatus
@react-native-firebase/messagingFCM delivery, token, topics~370,00022.xActive (Invertase)
@notifee/react-nativeDisplay, channels, actions~228,0009.1.8Active (Invertase)
@expo/expo-notificationsDelivery + display (Expo)~900,0000.32.xActive (Expo)
react-native-push-notificationLegacy all-in-one~76,000 (declining)8.xArchived Jan 2025
react-native-onesignalOneSignal SDK~45,0005.xActive

Flutter Libraries#

LibraryRolePub PointsLikesVersion
firebase_messagingFCM delivery, token160/160~2,20015.x
flutter_local_notificationsDisplay, channels, scheduling160/160~3,40019.x
awesome_notificationsDelivery + display (FCM-integrated)120/160~2,2000.10.x
onesignal_flutterOneSignal SDK130/160~4005.x

Server-Side Libraries#

LibraryLanguagePackageNotes
firebase-adminNode.js, Python, Java, Gonpm/pipOfficial; covers FCM + APNs relay
apns2Node.jsnpmDirect APNs HTTP/2; no Firebase dep
web-pushNode.jsnpmVAPID-based Web Push
@parse/node-apnNode.jsnpmFork of deprecated node-apn

Service Comparison#

ServiceFree TierSelf-HostedAPNs DirectFCMWeb PushOpen Source
OneSignalYes (unlimited push)NoSDK only
Expo PushYes (Expo apps only)NoNo
NovuYes (limited)Via FCM
FirebaseYes (Spark plan)Google infraVia FCM relaySDK
KnockPaidNoNo
CourierPaidNoNo

Critical Notes on Deprecated Libraries#

react-native-push-notification#

npm package: react-native-push-notification

Repository archived January 14, 2025 (read-only). Last meaningful commit: 2022. Issues accumulating with no responses from maintainer. Multiple security vulnerabilities open. Usage is declining (~76K/week, down from ~150K peak) as developers migrate.

Do not use for new projects. Migrate existing projects to @react-native-firebase/messaging + @notifee/react-native.

FCM Legacy API#

The https://fcm.googleapis.com/fcm/send endpoint using server keys was:

  • Deprecated: June 2023
  • Disabled: June 2024

Any server code hitting this endpoint now receives HTTP 401 or connection refused. Migrate to the v1 API with OAuth 2.0 service account tokens.


S3 Recommendation: Push Notification Libraries#

Top Choice Per Persona#

PersonaRecommendationStack
Mobile app (React Native)Firebase + Notifee@react-native-firebase/messaging + @notifee/react-native
Mobile app (Flutter)FlutterFirefirebase_messaging + flutter_local_notifications
Expo managed workflowExpo@expo/expo-notifications
Chat app (custom channels, actions)Same as aboveNotifee handles actions; Firebase handles delivery
E-commerce (segmentation, A/B)OneSignalManaged service; built-in segmentation
Web pushStandardweb-push npm + service worker
Server-side onlyfirebase-adminOfficial, polyglot, handles both FCM and APNs relay
Multi-channel (push + email + SMS)NovuOpen-source routing layer

When to Use a Managed Service vs. Direct Firebase#

Use OneSignal/Expo Push when:

  • Small team, no dedicated backend engineer for push
  • Need segmentation, A/B testing, analytics without building them
  • Want zero APNs/FCM credential management

Use Firebase directly when:

  • Privacy requirements (user tokens shouldn’t leave your infrastructure)
  • Custom delivery logic (retry, batching, per-user rate limiting)
  • Deep integration with existing Firebase infrastructure
  • Large scale (>10M devices) — cost can favor direct at scale

The Pairing Principle#

Push delivery and notification display are separate concerns in modern mobile development. The delivery library (firebase/messaging) gets the message to the device; the display library (notifee or flutter_local_notifications) controls how it appears. Understanding this separation prevents confusion about why “notifications don’t show when the app is open” (the delivery library doesn’t auto-display in foreground — you must call the display library).


S3 Use Cases: Push Notification Libraries#


Use Case 1: Basic Alert Push (React Native — Firebase + Notifee)#

Scenario: An app sends a message notification when the user receives a chat message. The notification shows in the status bar; tapping opens the relevant conversation.

// Server side (Node.js, firebase-admin)
const admin = require('firebase-admin');

async function sendMessageNotification(recipientToken, senderName, messageText) {
    await admin.messaging().send({
        token: recipientToken,
        // Data-only: app handles display for custom logic
        data: {
            type: 'new_message',
            sender: senderName,
            text: messageText.substring(0, 100),
        },
        android: { priority: 'high' },
        apns: {
            headers: { 'apns-priority': '10', 'apns-push-type': 'alert' },
            payload: { aps: { 'content-available': 1 } }
        }
    });
}
// React Native: foreground display via Notifee
import messaging from '@react-native-firebase/messaging';
import notifee, { AndroidImportance } from '@notifee/react-native';

// Create channel once at app startup
await notifee.createChannel({
    id: 'messages',
    name: 'Messages',
    importance: AndroidImportance.HIGH,
});

// Handle foreground messages
messaging().onMessage(async (remoteMessage) => {
    if (remoteMessage.data?.type === 'new_message') {
        await notifee.displayNotification({
            title: remoteMessage.data.sender,
            body: remoteMessage.data.text,
            android: { channelId: 'messages', pressAction: { id: 'default' } }
        });
    }
});

// Background handler (index.js, top level)
messaging().setBackgroundMessageHandler(async (remoteMessage) => {
    // Data updates; system shows notification based on FCM message
    // Or use Notifee for custom display
});

Use Case 2: iOS Image Attachment (Notification Service Extension)#

Scenario: An e-commerce app sends a push with a product image. The Notification Service Extension downloads and attaches the image before display.

// Server: include image_url in APNs payload
// apns-push-type: alert, mutable-content: 1
{
    "aps": {
        "alert": { "title": "Flash Sale!", "body": "iPhone case - 40% off" },
        "mutable-content": 1
    },
    "image_url": "https://cdn.example.com/product-123.jpg"
}
// NotificationService.swift
override func didReceive(_ request: UNNotificationRequest,
                         withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
    let content = request.content.mutableCopy() as! UNMutableNotificationContent

    guard let imageURLStr = request.content.userInfo["image_url"] as? String,
          let imageURL = URL(string: imageURLStr) else {
        contentHandler(content)
        return
    }

    URLSession.shared.downloadTask(with: imageURL) { tempURL, _, _ in
        if let tempURL = tempURL,
           let attachment = try? UNNotificationAttachment(identifier: "image", url: tempURL) {
            content.attachments = [attachment]
        }
        contentHandler(content)
    }.resume()
}

Use Case 3: Web Push Subscription and Send#

Scenario: A news site sends breaking news alerts to web subscribers (Chrome/Firefox/Safari desktop, Chrome Android).

// Step 1: Client subscribes (in browser)
async function subscribePush() {
    const reg = await navigator.serviceWorker.ready;
    const sub = await reg.pushManager.subscribe({
        userVisibleOnly: true,
        applicationServerKey: VAPID_PUBLIC_KEY  // base64url encoded
    });
    // POST sub to your server
    await fetch('/api/push/subscribe', {
        method: 'POST',
        body: JSON.stringify(sub),
        headers: { 'Content-Type': 'application/json' }
    });
}
// Step 2: Server sends notification (Node.js, web-push)
const webpush = require('web-push');
webpush.setVapidDetails(
    'mailto:[email protected]',
    process.env.VAPID_PUBLIC_KEY,
    process.env.VAPID_PRIVATE_KEY
);

async function sendBreakingNews(subscriptions, headline) {
    const payload = JSON.stringify({ title: 'Breaking News', body: headline });
    const results = await Promise.allSettled(
        subscriptions.map(sub => webpush.sendNotification(sub, payload))
    );
    // Collect errors; delete subscriptions with 410 status
    results.forEach((r, i) => {
        if (r.status === 'rejected' && r.reason?.statusCode === 410) {
            deleteSubscription(subscriptions[i]);
        }
    });
}
// Step 3: Service worker handles push event
self.addEventListener('push', event => {
    const data = event.data.json();
    event.waitUntil(
        self.registration.showNotification(data.title, { body: data.body })
    );
});

Use Case 4: Flutter — FCM with Local Notification Display#

Scenario: A Flutter app receives FCM messages and displays them with flutter_local_notifications (for custom UI on Android).

import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';

// Background handler (top-level)
@pragma('vm:entry-point')
Future<void> _bgHandler(RemoteMessage message) async {
    await Firebase.initializeApp();
    // Handle data
}

// In main()
void main() async {
    WidgetsFlutterBinding.ensureInitialized();
    await Firebase.initializeApp();
    FirebaseMessaging.onBackgroundMessage(_bgHandler);
    runApp(MyApp());
}

// In app initialization
final FlutterLocalNotificationsPlugin _localNotif = FlutterLocalNotificationsPlugin();

// Create Android channel
const channel = AndroidNotificationChannel(
    'high_importance_channel',
    'High Importance Notifications',
    importance: Importance.high,
);
await _localNotif.resolvePlatformSpecificImplementation<
    AndroidFlutterLocalNotificationsPlugin>()?.createNotificationChannel(channel);

// Display notification on foreground FCM message
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
    if (message.notification != null) {
        _localNotif.show(
            message.hashCode,
            message.notification!.title,
            message.notification!.body,
            NotificationDetails(
                android: AndroidNotificationDetails(
                    channel.id, channel.name,
                    importance: channel.importance,
                    icon: 'ic_notification',
                ),
            ),
        );
    }
});

Use Case 5: Topic Broadcast (Segmented Audience)#

Scenario: A sports app sends score alerts to users who subscribed to specific teams.

// Client subscribes to topic (React Native)
import messaging from '@react-native-firebase/messaging';
await messaging().subscribeToTopic('team-49ers');

// Server sends to topic (firebase-admin)
await admin.messaging().send({
    topic: 'team-49ers',
    notification: {
        title: '49ers Score Update',
        body: 'TD! 49ers 14 - Cowboys 7'
    },
    android: { priority: 'high' },
});

// Condition: users subscribed to BOTH 49ers AND playoff-alerts
await admin.messaging().send({
    condition: "'team-49ers' in topics && 'playoff-alerts' in topics",
    notification: { title: 'Playoff Alert', body: 'San Francisco advances!' }
});

Use Case 6: OneSignal — Simple Setup with Segmentation#

Scenario: A small team wants push notifications without managing APNs/FCM credentials directly.

// Server send via OneSignal REST API
const response = await fetch('https://onesignal.com/api/v1/notifications', {
    method: 'POST',
    headers: {
        'Authorization': `Basic ${process.env.ONESIGNAL_API_KEY}`,
        'Content-Type': 'application/json'
    },
    body: JSON.stringify({
        app_id: process.env.ONESIGNAL_APP_ID,
        // Target: all subscribed users
        included_segments: ['All'],
        // Or target by external user ID (your own user ID)
        // include_external_user_ids: ['user-123', 'user-456'],
        headings: { en: 'New Feature' },
        contents: { en: 'Dark mode is now available!' },
        // iOS
        ios_sound: 'default',
        ios_badge_type: 'Increase',
        ios_badge_count: 1,
        // Android
        android_channel_id: 'general-notifications',
    })
});
// React Native OneSignal SDK
import OneSignal from 'react-native-onesignal';

OneSignal.setAppId('YOUR_APP_ID');
OneSignal.promptForPushNotificationsWithUserResponse();

// Set external user ID (link OneSignal device to your user)
OneSignal.setExternalUserId('user-123');

// Handle notification opened
OneSignal.setNotificationOpenedHandler(notification => {
    const data = notification.notification.additionalData;
    navigation.navigate(data.screen);
});

Library Comparison Table#

Feature@react-native-firebase/messaging@notifee/react-nativefirebase_messaging (Flutter)flutter_local_notificationsOneSignal
PlatformReact NativeReact NativeFlutterFlutterCross-platform + Web
FCM delivery❌ (display only)❌ (display only)✅ (via gateway)
APNs deliveryVia FCM relayVia FCM relay✅ (direct)
Android channelsVia FCM config✅ (full control)Via FCM config✅ (full control)
iOS categories/actions
In-app display control
Background handler
Topics✅ (segments)
Server neededYesNo (client-side)YesNo (client-side)No (use their API)
Downloads/Popularity~370K/week~125K/week160 pts, ~2,200 likes160 pts, ~3,400 likesManaged service
LicenseApache 2.0Apache 2.0BSDMITProprietary SaaS

Decision Tree#

Need push notifications?
├── React Native app?
│   ├── Expo managed workflow → @expo/expo-notifications
│   └── Bare/custom → @react-native-firebase/messaging + @notifee/react-native
├── Flutter app?
│   └── firebase_messaging + flutter_local_notifications
├── Want managed service (no server infra)?
│   ├── Expo app → Expo Push Service
│   └── Any platform → OneSignal
├── Open-source multi-channel routing?
│   └── Novu (self-hosted)
├── Web push?
│   └── web-push npm + service worker (VAPID)
└── Server-side only?
    ├── Firebase ecosystem → firebase-admin SDK
    └── Direct APNs → apns2 npm package
S4: Strategic

S4 Strategic Analysis: Push Notification Libraries#

Research ID: 1.119.2 Pass: S4 — Strategic Analysis Date: 2026-02-17


iOS Opt-In Rates#

iOS push notifications require explicit user permission. Industry data (OneSignal, Airship, Braze reports) consistently shows:

  • iOS opt-in rate: 45–60% for most consumer apps (varies widely by category)
  • Utility apps (banking, healthcare, transit): 65–80% opt-in
  • Media/entertainment: 40–55% opt-in
  • E-commerce: 35–50% opt-in

Provisional authorization (iOS 12+) allows delivering notifications quietly without a permission prompt. Users who see quiet notifications can promote them to banner/sound. This strategy can increase effective reach — some developers see 15–20% additional coverage via provisional.

Note: Aggressive permission prompts (asking on first launch with no context) have been found to reduce opt-in rates vs. contextual prompts after demonstrating value.

Android 13+ Permission Change#

Android 13 (API 33, released 2022) added POST_NOTIFICATIONS as a runtime permission, matching iOS’s opt-in model. Previously, Android push was opt-out — users received notifications unless they manually disabled them.

Impact: Existing apps upgrading to target SDK 33+ must add the permission request flow. New installs on Android 13+ devices require explicit permission. Industry data shows Android opt-in rates dropping from near-100% toward iOS-like rates (~50–70%) for apps targeting Android 13+.

This is a convergence of iOS and Android toward similar opt-in permission models.


Notification Fatigue#

Notification overuse has led to habitual dismissal and opt-out escalation. Industry research shows:

  • Open rates for push notifications: 7–10% on iOS, 3–7% on Android (2025 benchmarks)
  • Users who opt out rarely re-enable notifications
  • Apps that send >2 promotional pushes/day see significantly higher opt-out rates

Strategic implication: Quality over quantity. Relevance and timing matter more than volume. Personalization (using user behavior data to target the right notification to the right person at the right time) correlates with higher opt-in retention.

Rich Notifications#

Rich notifications (images, action buttons, expandable content) show 2–4x higher engagement than text-only notifications. Supporting mutable-content + Notification Service Extension (iOS) and large icon/big picture styles (Android) is a worthwhile investment.


3. Multi-Channel Notification Routing Layers#

A growing category of tools sits above individual delivery channels (push, email, SMS, in-app) and provides:

  • Unified notification API across channels
  • User preference management (which channels they want)
  • Notification workflows (sequences, delays, fallbacks)
  • Templates and content management

Novu (open-source, ~34K GitHub stars):

  • Self-hostable; also cloud-hosted
  • Workflow DSL: define step → delay → condition → step
  • Multi-channel: push, email, SMS, in-app, chat
  • Growing adoption among teams wanting a unified notification layer

Knock:

  • SaaS, well-designed API
  • Per-notification tenant routing (multi-tenant SaaS products)
  • In-app notification feed component (React)

Courier:

  • SaaS, routing-focused
  • Strong in multi-channel orchestration

Strategic trend: As apps mature, the push library (firebase/messaging) handles delivery mechanics, while a routing layer handles orchestration logic. This separation of concerns is increasingly the architecture for mid-to-large scale apps.


4. Firebase/FCM Dependency Risk#

FCM is Google infrastructure. Risk factors:

  • Google has a history of shutting down services (Google Cloud Messaging → FCM → FCM v1 API)
  • The migration from legacy to v1 API in 2024 was a forced, breaking change
  • Apple silicon + security hardening makes server-to-device direct paths increasingly difficult

Mitigation options:

  • OneSignal/third-party: Vendor lock-in to OneSignal, but decoupled from FCM API changes
  • Direct APNs: For iOS-only scenarios, bypass FCM entirely with apns2 or firebase-admin
  • APNS + FCM abstraction: A thin server-side routing layer that handles credential management; swap out delivery providers without client code changes

Assessment: FCM is stable infrastructure for the medium term (3–5 years). Google’s enterprise cloud focus makes it likely to remain. The forced v1 migration shows Google will make breaking changes when necessary. Keeping server-side push code behind an abstraction layer is prudent.


5. Invertase Dependency Risk (React Native)#

Both @react-native-firebase and @notifee/react-native are maintained by Invertase, a UK-based React Native consultancy. This creates a dependency concentration:

  • Invertase has a strong commercial incentive to maintain these libraries (consulting revenue)
  • ~370K/week + ~125K/week downloads = significant ecosystem position
  • In 2022, Invertase paused notifee open-source development temporarily (moved to paid plan)
  • Community pressure reversed this; notifee returned to open-source MIT license

Assessment: Medium risk. Invertase is a professional organization with commercial incentives aligned with maintenance. The temporary notifee commercial pivot and reversal shows community leverage works. For mission-critical applications, having a fallback plan (native module capability) is prudent.


6. Web Push Future#

Safari’s 2022–2023 Web Push support was a significant shift. Full browser coverage now exists (Chrome, Firefox, Edge, Safari macOS, Safari iOS 16.4+). However:

  • iOS Web Push requires Home Screen PWA installation
  • iOS Safari Web Push cannot match native APNs for reach and reliability
  • Service worker background sync limitations on iOS are ongoing

Assessment: Web Push is now a viable complementary channel for PWA-first products and web-heavy products with desktop users. It does not replace native mobile push for mainstream consumer apps. The combination of native + web push will become standard for apps targeting both native and web users.


S4 Approach: Push Notification Libraries#

Research ID: 1.119.2 Pass: S4 — Strategic Analysis Date: 2026-02-17

Evaluation Dimensions#

  1. Platform policy trends: iOS permission opt-in rates, Android 13 permission change, OS-level throttling
  2. Push engagement trends: notification fatigue, opt-out rates, alternatives (in-app messaging, email)
  3. Multi-channel future: notification routing layers (Novu, Knock, Courier) as higher-order abstraction
  4. Web Push maturity: Safari adoption, PWA trends, impact on cross-platform strategy
  5. Firebase dependency risk: FCM as Google infrastructure, open-source alternatives
  6. Library longevity: Invertase (react-native-firebase + notifee) as single-point dependency

S4 Recommendation: Push Notification Libraries#

Definitive Stack Recommendation#

React Native#

Delivery: @react-native-firebase/messaging (v21.x, ~370K/week) — FCM-based delivery for Android; routes through FCM to APNs for iOS. Display: @notifee/react-native (v9.x, ~125K/week) — Android channels, iOS categories, notification actions, scheduling. Note: HCE and delivery are separate concerns. Wire onMessage to notifee display calls.

Flutter#

Delivery: firebase_messaging (v15.x, FlutterFire, 160 pub points, ~2,200 likes). Display: flutter_local_notifications (v18.x, 160 pub points, ~3,400 likes). Alternative: awesome_notifications if richer display customization is needed.

Expo#

@expo/expo-notifications — for Expo managed workflow. Uses Expo Push Service (free, handles APNs/FCM credentials). Migrate to raw firebase/notifee if leaving Expo.

Server-Side#

firebase-admin (Node.js/Python/Java/Go) — official, covers FCM + APNs relay, topic messaging, multicast. apns2 — for direct APNs without Firebase dependency. web-push — for Web Push / VAPID.

Managed Service#

OneSignal — if segmentation, A/B testing, analytics, and minimal server-side code are priorities. Free tier covers unlimited push. Novu — if open-source, self-hosted multi-channel routing is a priority.


The One Principle#

Delivery ≠ Display. Firebase/FCM delivers the message to the device; your display library controls the notification UI. The OS shows notification messages automatically in background/killed state. In the foreground, your app code must explicitly call the display library. Design for both states.


Critical Implementation Checklist#

Backend:
[ ] FCM v1 API with service account OAuth (not legacy API)
[ ] APNs token-based auth (.p8 key, not certificate)
[ ] Delete stale tokens on APNs 410 / FCM NotRegistered

Android:
[ ] Create notification channels at app startup (required Android 8+)
[ ] Request POST_NOTIFICATIONS permission (required Android 13+)
[ ] Use data-only messages for custom background logic

iOS:
[ ] Request push permission with context (not on first launch)
[ ] Consider provisional authorization for quiet delivery
[ ] Implement Notification Service Extension for image attachments
[ ] Handle APNs token rotation via didRegisterForRemoteNotificationsWithDeviceToken

Web Push:
[ ] Generate VAPID keys once, store securely
[ ] Serve site over HTTPS
[ ] Register service worker before subscribe call
[ ] Handle 410 responses to delete expired subscriptions

Sources#


S4 Viability: Push Notification Libraries#

Research ID: 1.119.2 Pass: S4 — Viability Assessment Date: 2026-02-17


Long-Term Viability Scores#

Technology5-Year ViabilityRationale
APNs (Apple)✅ HighApple’s push infrastructure; no alternatives exist for iOS
FCM (Google)✅ HighCore Android infrastructure; Google cloud investment confirmed
@react-native-firebase/messaging🟡 Medium-HighSingle maintainer org (Invertase); no serious alternative exists
@notifee/react-native🟡 MediumInvertase; had commercial pivot scare in 2022
firebase_messaging (Flutter)✅ HighFlutterFire is Google-supported; community-maintained
flutter_local_notifications✅ HighIndividual maintainer but extremely well-maintained and widely depended on
Web Push (VAPID)✅ HighIETF standard; full browser coverage achieved
OneSignal🟡 MediumSaaS; profitable private company; risk of acquisition or pricing change
Novu🟡 MediumOpen-source; growing; requires self-hosting commitment
react-native-push-notification❌ DeadUnmaintained since 2022

Risk Matrix#

RiskLikelihoodImpactMitigation
FCM breaking change againMediumHighKeep server push code behind abstraction
Invertase stops maintaining notifeeLowHighNative modules fallback; community fork likely
iOS push opt-in rates continue decliningHighMediumInvest in contextual permission requests; provisional auth
Android 13+ permission adoption reduces reachHigh (already happening)MediumTreat Android like iOS for permission UX
Firebase data privacy requirementsMediumMediumDirect APNs/FCM without Firebase SDK is possible
Web Push never achieves iOS parityHigh (structural Apple constraint)Low (native covers it)Native push remains primary for mobile iOS

Investment Recommendation#

Low-risk foundation: APNs + FCM delivered via firebase-admin on the server. These are platform infrastructure — not going anywhere.

Primary client stack (React Native): @react-native-firebase/messaging + @notifee/react-native. Monitor Invertase’s organizational stability; have native fallback plan for critical features.

Primary client stack (Flutter): firebase_messaging + flutter_local_notifications. Both well-maintained; lower organizational risk than Invertase.

Consider for scale: Routing layer (Novu for self-hosted, OneSignal/Knock for managed) as the app grows beyond basic delivery. Decouples notification logic from delivery library changes.

Invest in: Contextual permission request UX — the single highest-leverage action for improving notification reach on both iOS and Android 13+. Better timing of permission prompts correlates with 15–30% higher opt-in rates.

Avoid: New projects on react-native-push-notification. Technical debt compounds fast on unmaintained libraries.

Published: 2026-03-06 Updated: 2026-03-06