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
- 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.
- iOS / Android → APNs / FCMRegister this app on this device
- 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.
- 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.
- Your server → APNs / FCMPOST message with JWT / OAuth 2.0 tokenAPNs: HTTP/2 to /3/device/<token>. FCM: POST /v1/projects/<id>/messages:send.
- APNs / FCM → Your server200 accepted (not yet delivered)
- 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.
- 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.
- App on device → Your serverTap: open deep link, fetch fresh state
- Your server → APNs / FCMLater: send to an uninstalled app's token
- 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 | FCM (HTTP v1) | Web Push | |
|---|---|---|---|
| Server authentication | ES256 JWT from a .p8 key, refreshed every 20-60 min; or TLS cert | Short-lived OAuth 2.0 token from a service account | VAPID JWT signed with your key pair (RFC 8292) |
| Address | Device token per app per device | Registration token per app instance | PushSubscription endpoint URL plus p256dh and auth keys |
| Transport | HTTP/2 to api.push.apple.com (or sandbox), port 443 or 2197 | HTTPS POST to fcm.googleapis.com/v1/projects/<id>/messages:send | HTTP POST to the endpoint (RFC 8030), payload encrypted (RFC 8291) |
| Payload limit | 4 KB (5 KB for VoIP) | 4096 bytes | Push services must accept at least 4096 bytes |
| Priority | apns-priority 10 now, 5 power-aware, 1 lowest | android.priority high or normal; unused high gets demoted | Urgency: very-low, low, normal, high |
| Offline storage | Until apns-expiration (up to 30 days); latest one per app | ttl up to 28 days (default); 0 = now or never | TTL header is required; 0 = deliver now or drop |
| Replace pending message | apns-collapse-id (max 64 bytes) | collapse_key (Android); apns-collapse-id via apns.headers | Topic header replaces a pending message |
| Background / data | content-available: 1, push-type background, priority 5; 2-3 per hour | Data-only message handled in onMessageReceived | push event in the service worker; must show a notification |
| Dead address signal | 410 Unregistered or ExpiredToken | 404 UNREGISTERED (400 INVALID_ARGUMENT if payload is valid) | 404 or 410 from the endpoint |
| User permission | UNUserNotificationCenter; provisional = quiet trial | POST_NOTIFICATIONS on Android 13+; channels decide behavior | Notification.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.
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.
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.
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.
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.
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.
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.WHEN IT BREAKS IN PRODUCTION
SOURCES
- Apple: Sending notification requests to APNs (opens in new tab)Checked
- Apple: Establishing a token-based connection to APNs (opens in new tab)Checked
- Firebase: Best practices for FCM registration token management (opens in new tab)Checked
- Firebase: Android message priority (opens in new tab)Checked
- Firebase: Migrate to AndroidConfigV2 (preview) (opens in new tab)Checked
- Apple: Handling notification responses from APNs (opens in new tab)Checked
- Android: Notification runtime permission (opens in new tab)Checked
- Expo: Send notifications with the Expo Push Service (opens in new tab)Checked
Explainer reviewed