← Back to the library
MobileHow it works · Applied · 14 min

Push notifications: from your server through APNs and FCM to the device

What actually happens between "send notification" on your server and a banner on a phone, and why do some never arrive?

Your server never talks to the phone. It hands a message to the platform's push service (APNs for Apple, FCM for Android, a browser vendor's service for Web Push), addressed by a device token, and that service delivers it over a connection the OS already keeps open, on its own schedule. A 200 response means accepted, not delivered.

THE MENTAL MODEL

Treat push as a postcard to a forwarding address. The device token is the address: issued by the push service to one app on one device, it can change, and it goes dead when the app is uninstalled, so your database of tokens has its own lifecycle. The push service is the post office: it authenticates you (a JWT for APNs, an OAuth 2.0 token for FCM, VAPID for Web Push), stores the message for a while if the device is offline, keeps only the latest one per collapse key, and delays or drops low-priority traffic to save battery. The OS is the mailbox: it decides whether the user sees anything, based on permission, channel or interruption level, Focus and the app's state. Design for loss: a push is a hint that something changed, and the app fetches the truth from your API when it opens. Web Push is the same shape for browsers; the client-server-communication explainer covers when to use it instead of a live connection.

HOW IT FITS TOGETHER

Registration, send and deliverySteps 1-4 happen on the device at startup; 5-9 for every message; 10-11 are how dead tokens are discovered. FCM on iOS adds one hop: FCM forwards to APNs using the key you uploaded to Firebase.
  1. App on device → iOS / AndroidRequest permission (alert, sound, badge)iOS prompts once; Android 13+ needs the POST_NOTIFICATIONS runtime permission. Ask in context, after the user sees the value.
  2. iOS / Android → APNs / FCMRegister this app on this device
  3. APNs / FCM → App on deviceDevice token (APNs) / registration tokenUnique per app per device. Can change after reinstall, restore or token rotation; request it on every launch.
  4. App on device → Your serverUpsert {token, platform, env, userId}Store the APNs environment (sandbox or production) and the last-seen time; unlink the token on logout.
  5. Your server → APNs / FCMPOST message with JWT / OAuth 2.0 tokenAPNs: HTTP/2 to /3/device/<token>. FCM: POST /v1/projects/<id>/messages:send.
  6. APNs / FCM → Your server200 accepted (not yet delivered)
  7. APNs / FCM → iOS / AndroidDeliver over the OS's persistent connectionIf the device is offline the message is stored until expiry, and only the latest per app (APNs) or per collapse key (FCM) is kept. Low priority may wait for the device to wake.
  8. iOS / Android → App on deviceShow alert, or wake app for data messageDisplay depends on permission, channel importance or interruption level, and Focus. Background wakes are throttled.
  9. App on device → Your serverTap: open deep link, fetch fresh state
  10. Your server → APNs / FCMLater: send to an uninstalled app's token
  11. APNs / FCM → Your server410 Unregistered / 404 UNREGISTEREDThe only reliable signal that a token is dead. Delete it, unless the device re-registered after the APNs timestamp.
APNs vs FCM vs Web PushWhat your server deals with for each push service. Expo's push service wraps APNs and FCM behind one token format and one HTTP API.
APNsFCM (HTTP v1)Web Push
Server authenticationES256 JWT from a .p8 key, refreshed every 20-60 min; or TLS certShort-lived OAuth 2.0 token from a service accountVAPID JWT signed with your key pair (RFC 8292)
AddressDevice token per app per deviceRegistration token per app instancePushSubscription endpoint URL plus p256dh and auth keys
TransportHTTP/2 to api.push.apple.com (or sandbox), port 443 or 2197HTTPS POST to fcm.googleapis.com/v1/projects/<id>/messages:sendHTTP POST to the endpoint (RFC 8030), payload encrypted (RFC 8291)
Payload limit4 KB (5 KB for VoIP)4096 bytesPush services must accept at least 4096 bytes
Priorityapns-priority 10 now, 5 power-aware, 1 lowestandroid.priority high or normal; unused high gets demotedUrgency: very-low, low, normal, high
Offline storageUntil apns-expiration (up to 30 days); latest one per appttl up to 28 days (default); 0 = now or neverTTL header is required; 0 = deliver now or drop
Replace pending messageapns-collapse-id (max 64 bytes)collapse_key (Android); apns-collapse-id via apns.headersTopic header replaces a pending message
Background / datacontent-available: 1, push-type background, priority 5; 2-3 per hourData-only message handled in onMessageReceivedpush event in the service worker; must show a notification
Dead address signal410 Unregistered or ExpiredToken404 UNREGISTERED (400 INVALID_ARGUMENT if payload is valid)404 or 410 from the endpoint
User permissionUNUserNotificationCenter; provisional = quiet trialPOST_NOTIFICATIONS on Android 13+; channels decide behaviorNotification.requestPermission() from a user gesture

KEY TERMS

Device token lifecycle
Tokens are issued per app per device and change after reinstall, backup restore or rotation. Re-register on every launch and upsert server-side; delete on 410/UNREGISTERED. FCM expires Android registrations inactive for 270 days and treats a month without a refresh as stale, so store a last-seen timestamp and prune. FCM is moving its registrations to Firebase Installation IDs; registration tokens remain fully supported.
Notification vs data messages
A notification (alert) payload is displayed by the OS even if your app is not running. A data (background) payload is delivered to your code, which must be woken to handle it, so it is subject to throttling and app state. FCM notification messages in the background go straight to the tray; your handler sees the data only when the user taps.
Priority and throttling
APNs priority 10 delivers immediately, 5 and 1 can be grouped into bursts based on power. Apple asks for no more than two or three background pushes an hour and drops held ones if the user force-quits the app. FCM high priority can wake a dozing device, but if your high-priority messages do not lead to visible notifications over about 7 days, FCM demotes them to normal.
Storage, expiry and collapsing
Push services are store-and-forward, best effort, and may reorder messages. APNs stores only one notification per app for an offline device; FCM keeps messages up to their TTL and replaces ones with the same collapse key. Set short expiries for stale-quickly content (a ride arriving) and collapse ids for counters and status.
iOS authorization and interruption levels
Provisional authorization grants quiet delivery to Notification Center without a prompt, with Keep and Turn Off buttons. Each notification has an interruption level: passive, active, time-sensitive (breaks through Focus and the summary; needs the Time Sensitive capability) or critical (needs an entitlement Apple grants on request).
Android channels and POST_NOTIFICATIONS
Since Android 8 every notification needs a channel, and after creation the user, not your app, controls its importance and sound. Since Android 13 (API 33) notifications are off for new installs until the user grants POST_NOTIFICATIONS; apps targeting 13+ decide when to ask, and a denied permission blocks all channels.
Expo push service
An HTTP API in front of APNs and FCM addressed by ExponentPushToken values. Sending returns tickets (Expo accepted it), and receipts fetched about 15 minutes later report what APNs or FCM said, including DeviceNotRegistered. Up to 100 messages per request; receipts are kept for 24 hours. You still configure APNs keys and FCM credentials in EAS.

IN YOUR STACK

TypeScript · Node.js http2 + APNs Server

import { connect } from "node:http2";
import { createPrivateKey, sign } from "node:crypto";
const { APNS_KEY_P8, KEY_ID, TEAM_ID, BUNDLE_ID } = process.env as Record<string, string>;
const key = createPrivateKey(APNS_KEY_P8);
let cached = { jwt: "", iat: 0 };
function providerToken(): string {
  const now = Math.floor(Date.now() / 1000);
  if (now - cached.iat < 40 * 60) return cached.jwt; // refresh every 20-60 min, never per request
  const b64 = (o: object) => Buffer.from(JSON.stringify(o)).toString("base64url");
  const input = `${b64({ alg: "ES256", kid: KEY_ID })}.${b64({ iss: TEAM_ID, iat: now })}`;
  const sig = sign("sha256", Buffer.from(input), { key, dsaEncoding: "ieee-p1363" }).toString("base64url");
  return (cached = { jwt: `${input}.${sig}`, iat: now }).jwt;
}

const apns = connect("https://api.push.apple.com"); // long-lived; many concurrent streams
apns.on("error", (err) => console.error("APNs session error", err)); // unhandled, it crashes the process
export function sendAlert(token: string, payload: object): Promise<{ status: number; body: string }> {
  return new Promise((resolve, reject) => {
    const req = apns.request({ ":method": "POST", ":path": `/3/device/${token}`, authorization: `bearer ${providerToken()}`,
      "apns-topic": BUNDLE_ID, "apns-push-type": "alert", "apns-priority": "10", "apns-expiration": String(Math.floor(Date.now() / 1000) + 3600) });
    let status = 0, body = "";
    req.on("response", (h) => (status = Number(h[":status"]))).on("data", (c) => (body += c));
    req.on("end", () => resolve({ status, body })).on("error", reject);
    req.end(JSON.stringify(payload)); // { aps: { alert: { title, body }, "interruption-level": "time-sensitive" } }
  });
}
  • JWTs must be ES256 with kid and iss/iat; an iat older than an hour returns 403 ExpiredProviderToken, and a new token more often than every 20 minutes on one connection is also an error.
  • Development builds get sandbox tokens: send those to api.sandbox.push.apple.com. Mixing environments returns 400 BadDeviceToken, which is a config bug, not a dead token.
  • Reuse the HTTP/2 connection and reconnect on GOAWAY. In production most teams use a maintained client (for example @parse/node-apn) rather than raw http2.
Node.js http2 + APNs documentation (opens in new tab)

TypeScript · FCM HTTP v1 API Server

import { GoogleAuth } from "google-auth-library";

const auth = new GoogleAuth({ scopes: ["https://www.googleapis.com/auth/firebase.messaging"] });
const PROJECT = process.env.FIREBASE_PROJECT_ID!;

export async function sendOrderShipped(token: string, orderId: string) {
  const accessToken = await auth.getAccessToken(); // short-lived, cached and refreshed by the library
  const res = await fetch(`https://fcm.googleapis.com/v1/projects/${PROJECT}/messages:send`, {
    method: "POST",
    headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
    body: JSON.stringify({
      message: {
        token,
        notification: { title: "Order shipped", body: "Tap to track your parcel" },
        data: { orderId, type: "order_shipped" }, // string values only
        android: { priority: "high", ttl: "3600s", collapse_key: `order-${orderId}`, notification: { channel_id: "orders" } },
        apns: { headers: { "apns-priority": "10", "apns-collapse-id": `order-${orderId}` } },
      },
    }),
  });
  const json = await res.json();
  if (!res.ok) return { ok: false as const, status: res.status, error: json.error }; // see token cleanup tab
  return { ok: true as const, id: json.name as string }; // "projects/<id>/messages/<message-id>"
}
  • The legacy FCM server-key APIs are gone; HTTP v1 needs an OAuth 2.0 token from a service account (Application Default Credentials on Google Cloud).
  • One request targets one token, topic or condition. For fan-out send concurrently with a bounded pool and back off on 429 QUOTA_EXCEEDED and 503 UNAVAILABLE, honouring Retry-After.
  • Use high priority only for messages that show a notification; FCM watches a 7-day window and demotes apps whose high-priority messages stay invisible.
  • AndroidConfigV2 (`android_v2`) is in preview: it replaces the priority field with a message type, remote_notification or background_sync. The `android` block shown here is still the stable API.
FCM HTTP v1 API documentation (opens in new tab)

TypeScript · expo-notifications + Expo push Client + server

import * as Notifications from "expo-notifications";
import Constants from "expo-constants";
import { Platform } from "react-native";
Notifications.setNotificationHandler({ // presentation while the app is in the foreground
  handleNotification: async () => ({ shouldShowBanner: true, shouldShowList: true, shouldPlaySound: false, shouldSetBadge: false }),
});
export async function registerForPush(api: { post(path: string, body: object): Promise<unknown> }) {
  if (Platform.OS === "android") {
    await Notifications.setNotificationChannelAsync("orders", { name: "Order updates", importance: Notifications.AndroidImportance.HIGH });
  }
  let { status } = await Notifications.getPermissionsAsync();
  if (status !== "granted") ({ status } = await Notifications.requestPermissionsAsync());
  if (status !== "granted") return null;
  const projectId = Constants.expoConfig?.extra?.eas?.projectId ?? Constants.easConfig?.projectId;
  const { data: token } = await Notifications.getExpoPushTokenAsync({ projectId }); // "ExponentPushToken[...]"
  await api.post("/devices", { token, platform: Platform.OS });
  return token;
}

// Server: up to 100 messages per request; keep the ticket ids and fetch receipts later.
export const sendViaExpo = (to: string, accessToken: string) =>
  fetch("https://exp.host/--/api/v2/push/send", { method: "POST",
    headers: { "Content-Type": "application/json", Authorization: `Bearer ${accessToken}` },
    body: JSON.stringify([{ to, title: "Order shipped", body: "Tap to track", channelId: "orders", priority: "high", data: { orderId: "o_123" } }]) });
  • Remote push does not work in Expo Go on Android from SDK 53; use a development build. Subscribe with addPushTokenListener to re-upload the token when it changes.
  • A ticket with status ok only means Expo accepted the message. Fetch receipts from /--/api/v2/push/getReceipts after about 15 minutes; DeviceNotRegistered means stop sending to that token.
  • Enable enhanced push security in the Expo dashboard so only requests carrying your access token are delivered; otherwise anyone holding a token can send to it.
expo-notifications + Expo push documentation (opens in new tab)

Kotlin · Android NotificationChannel Client

object Channels {
    const val ORDERS = "orders"
    const val PROMOS = "promos"
    // Idempotent: recreating a channel with the same values is a no-op, so call it on every start.
    fun register(context: Context) {
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
        val nm = context.getSystemService(NotificationManager::class.java)
        nm.createNotificationChannels(listOf(
            NotificationChannel(ORDERS, context.getString(R.string.channel_orders), NotificationManager.IMPORTANCE_HIGH)
                .apply { description = context.getString(R.string.channel_orders_desc) },
            NotificationChannel(PROMOS, context.getString(R.string.channel_promos), NotificationManager.IMPORTANCE_LOW),
        ))
    }
}

// Ask in context on Android 13+ (API 33), e.g. from a "Notify me when it ships" button.
@Composable
fun NotifyMeButton(onResult: (Boolean) -> Unit) {
    val context = LocalContext.current
    val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission(), onResult)
    Button(onClick = {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
        else onResult(NotificationManagerCompat.from(context).areNotificationsEnabled())
    }) { Text("Notify me when it ships") }
}
  • Declare <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/> in the manifest. Apps targeting 12L or lower get the prompt automatically when the first channel is created, and one denial sticks until reinstall.
  • Once created, only a channel's name and description can change; importance, sound and vibration belong to the user. Ship new behavior as a new channel ID.
  • Split channels by what users want to control (orders vs promotions). One catch-all channel means one switch, and users turn it off.
Android NotificationChannel documentation (opens in new tab)

Swift · UserNotifications Client

import UIKit
import UserNotifications
@MainActor
func enableNotifications(quietTrial: Bool) async {
    let center = UNUserNotificationCenter.current()
    switch await center.notificationSettings().authorizationStatus {
    case .denied:
        return // no second prompt: offer a button that opens Settings instead
    case .notDetermined: // .provisional: no prompt now; delivered quietly to Notification Center
        var options: UNAuthorizationOptions = [.alert, .sound, .badge]
        if quietTrial { options.insert(.provisional) }
        guard (try? await center.requestAuthorization(options: options)) == true else { return }
    default: break // .authorized, .provisional, .ephemeral
    }
    UIApplication.shared.registerForRemoteNotifications() // token arrives in the app delegate
}
// AppDelegate
func application(_ app: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken token: Data) {
    let hex = token.map { String(format: "%02x", $0) }.joined()
    Task { try await api.upsertDevice(token: hex, environment: apsEnvironment) } // every launch
}
func application(_ app: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
    logger.warning("APNs registration failed: \(error)") // no network or missing entitlement; retry later
}
  • Requires the Push Notifications capability (aps-environment entitlement). Time-sensitive delivery additionally needs the Time Sensitive Notifications capability and "interruption-level": "time-sensitive" in the payload.
  • Provisional is a good default for content apps: you skip the one-shot prompt and let people promote you with Keep once they have seen real notifications.
  • Check authorization status on every foreground: users can turn notifications off in Settings at any time, and your server should stop sending alerts to them.
UserNotifications documentation (opens in new tab)

TypeScript · Server-side token hygiene Server

type PushResult =
  | { provider: "apns"; status: number; reason?: string; timestamp?: number }
  | { provider: "fcm"; status: number; errorCode?: string }
  | { provider: "expo"; status: number; error?: string } // from the ticket or the receipt
  | { provider: "webpush"; status: number };
declare const db: { query(sql: string, args: unknown[]): Promise<unknown> };
declare const queue: { retryWithBackoff(deviceId: string, r: PushResult): Promise<void> };
// true only when the address is permanently dead.
function isDeadToken(r: PushResult): boolean {
  switch (r.provider) {
    case "apns": return r.status === 410; // 400 BadDeviceToken is usually a sandbox/production mix-up: alert, don't delete
    case "fcm": return r.errorCode === "UNREGISTERED"; // INVALID_ARGUMENT only once you're sure the payload is valid
    case "expo": return r.error === "DeviceNotRegistered";
    case "webpush": return r.status === 404 || r.status === 410;
  }
}
export async function handleResult(deviceId: string, r: PushResult, sentAt: Date) {
  if (isDeadToken(r)) {
    // APNs 410 carries the moment the token became invalid; keep it if the device re-registered later.
    const invalidSince = r.provider === "apns" && r.timestamp ? new Date(r.timestamp) : sentAt;
    await db.query("DELETE FROM devices WHERE id = $1 AND registered_at < $2", [deviceId, invalidSince]);
  } else if (r.status === 429 || r.status >= 500) {
    await queue.retryWithBackoff(deviceId, r); // exponential backoff with jitter; honour Retry-After
  }
}
  • Never delete tokens on generic 4xx errors: a bad payload or expired provider credentials would wipe your whole device table in one campaign.
  • Also unlink a token from the user on logout. Otherwise the next person to sign in on that phone receives the previous user's notifications.
  • Prune tokens not refreshed for months even without errors; FCM treats a month without a refresh as stale and Android registrations inactive for 270 days as expired.
Server-side token hygiene documentation (opens in new tab)

WHEN TO REACH FOR WHAT

Direct APNs + FCM

Use when You want full control of headers, priorities, Live Activities and VoIP pushes, and no extra party in the delivery path.

Avoid when A small team that does not want to own two protocols, credential rotation and per-provider error handling.

FCM for both platforms

Use when One API and token type for iOS and Android, topics and multi-platform targeting, already on Firebase.

Avoid when You need APNs-specific features FCM does not expose cleanly, or you cannot add the Firebase SDK to the iOS app.

Expo push service

Use when Expo apps that want one token format and one simple HTTP API, with tickets and receipts, quickly.

Avoid when Very high volume or strict data-handling requirements: it adds a third party and a hop; use getDevicePushTokenAsync and send directly instead.

Web Push (VAPID)

Use when Re-engaging users of a website or installed PWA while it is closed, including Home Screen web apps on iOS.

Avoid when You already have a native app on the device, or you need reliable delivery and rich native notification features.

WHERE IT BITES

  • Treating 200 from APNs or FCM (or an ok Expo ticket) as delivered. It only means accepted; delivery is best effort, can be delayed, collapsed or dropped, and has no receipt from the device.
  • Putting secrets or full content in the payload. It passes through Apple, Google and possibly Expo and shows on the lock screen; send an ID and let the app fetch details over your authenticated API.
  • Prompting for permission on first launch. iOS shows its prompt once and a denial is permanent until Settings; ask after a user action that explains the value, or start with provisional.
  • Using silent or high-priority data pushes as a sync engine. iOS throttles them to a few per hour and ignores them for force-quit apps; FCM demotes high priority that never shows anything.
  • Keeping tokens forever and attached to whoever signed in first. Dead tokens waste quota, and stale user links leak one user's notifications to another on a shared device.
  • Sending a burst to millions of tokens with naive retries. Fan out through a queue with bounded concurrency and backoff, or 429s and 503s turn into a retry storm.

CLOSE THE AI. EXPLAIN THIS.

A user reinstalls your app and stops getting order notifications, while your logs show 200s from FCM for their old token. Walk the chain (permission, token registration, server storage, push service, OS display rules) and say which signals would tell you where it broke.

SOURCES

Explainer reviewed