How push notifications reach your phone
How APNs and FCM maintain persistent connections, handle token management, topic-based routing, and delivery confirmation for billions of daily notifications.
The scenario
An application server wants to alert a phone that may be asleep, offline, backgrounded, or no longer running the app. It cannot hold an application-owned socket open all day on every device, and a notification that arrives late or contains sensitive data can be worse than no notification.
Push delivery therefore crosses trust and power boundaries: the app server submits to a platform provider, the operating system decides when and how to present or deliver it, and the app handles the resulting callback when appropriate.
30-second mental model
The app registers a device with the operating system and receives a provider token. The backend stores that token with an application/user binding, sends a small message to APNs, FCM, or another provider, and treats the result as best effort unless the product has a separate durable inbox. Tokens expire or rotate, delivery is not an ordering guarantee, and user-visible presentation is controlled by the OS.
5-minute end-to-end flow
- Request permission and register with the platform; send the current token to the backend over an authenticated channel.
- Store token metadata such as user, app/environment, platform, locale, and last-seen time; accept token rotation.
- Create a notification from a durable domain event, removing secrets and putting only safe routing/display data in the payload.
- Submit through the platform provider with an appropriate expiry, collapse policy, and audience; keep provider-specific semantics behind an adapter.
- Process success and token-invalid errors, but do not treat provider acceptance as proof that a person saw the alert.
- On app open, fetch the durable inbox or current resource state so a missed or collapsed push does not lose business data.
The Architecture
Before the diagram, let me set the mental model. The entire push notification system has three actors, and most engineers only think about two of them.
Actor 1: Your server (the sender). Actor 2: The phone (the receiver). Actor 3: The platform push service (APNs or FCM), which is the invisible relay between the other two. Your server never talks to the phone. The platform is always in the middle.
This three-actor model is not optional. Apple and Google designed it this way for two reasons: battery efficiency (one shared connection per device instead of one per app) and security (the platform authenticates both sides and prevents spam).
Here is the full path. Your notification service builds a payload (title, body, badge count, custom data) and puts it into a send queue. The queue handles rate limiting (APNs allows bursts but can throttle) and retry logic.
The queue sends the notification over HTTP/2 with TLS to the platform push service. For iOS users, it goes to APNs. For Android, it goes to FCM. Each request includes the device token (a unique identifier for that device-app combination) and the notification payload.
The platform push service looks up which persistent connection belongs to that device token and routes the notification over it. The device's OS-level push daemon receives it, identifies which app it belongs to, and either shows it on the lock screen (if the app is backgrounded) or delivers it to the app's notification handler (if the app is in the foreground).
The critical insight: your server never talks to the phone directly. The platform is always the intermediary. This is why push notifications work even when the phone changes IP addresses, switches between WiFi and cellular, or moves between cell towers.
The payload itself has a strict format. For APNs, it is a JSON object with an aps key containing alert (title, body, subtitle), badge (the red number on the app icon), sound, and content-available (for silent notifications). For FCM, you choose between a notification object (platform-handled display) or a data object (app-handled processing). The maximum payload size is 4KB for APNs and 4KB for FCM data messages.
Here is what a typical APNs payload looks like:
{
"aps": {
"alert": {
"title": "New message from Alice",
"body": "Hey, are you free for lunch?"
},
"badge": 3,
"sound": "default"
},
"conversation_id": "conv-42",
"message_id": "msg-789"
}
The aps key is required and platform-defined. Everything outside aps is custom data that your app receives when the user taps the notification. This is how your app knows which conversation to open.
For FCM, the equivalent data message looks similar but uses data as the top-level key, and the display is handled entirely by your app code.
Both APNs and FCM use a single persistent connection per device, not per app. If you have 30 apps installed with push notifications enabled, all 30 share one connection to APNs. This is how Apple and Google handle billions of devices without billions of separate connections per app.
The Persistent Connection Architecture
This is the piece most engineers do not understand. Every iPhone maintains a single, always-on TLS connection to APNs. Every Android phone does the same with FCM. This is what makes push notifications "push" instead of "poll."
The connection is established when the phone boots (or when the OS push service starts) and it stays alive indefinitely. The phone and platform exchange heartbeat pings every 15-25 minutes (the interval varies by network conditions and battery state) to keep the connection from being dropped by NAT middleboxes.
Why heartbeats? Because the phone is behind a NAT (Network Address Translation) device, either the home router or the cellular carrier's NAT. NAT devices track active connections in a table and drop entries that have been idle too long. Without heartbeats, the NAT drops the mapping after 5-30 minutes (carrier-dependent), and the persistent connection silently dies. The phone thinks it is still connected, but packets from APNs can no longer reach it. The heartbeat keeps the NAT entry alive.
When the phone switches networks (WiFi to cellular, or between cell towers), the old TCP connection dies. The phone detects this and establishes a new connection. Any notifications that were queued during the brief disconnection are delivered immediately on the new connection.
The important point is something: this persistent connection is managed by the operating system, not by your app. Your app cannot open its own persistent connection to your server efficiently (the OS will kill background network connections to save battery). This is precisely why the platform intermediary exists.
Here is a number that puts this in perspective. Apple has over 1.5 billion active devices. Each device maintains one persistent connection to APNs. That is 1.5 billion concurrent TCP connections. Apple's push infrastructure is one of the largest persistent connection systems ever built, rivaling only Google's FCM fleet and WhatsApp's Erlang-based connection servers.
The heartbeat interval is also more nuanced than it first appears. On WiFi, the interval can be longer (up to 30 minutes) because NAT tables in home routers are generous. On cellular, NAT tables expire faster (some carriers kill idle connections after 5 minutes), so the heartbeat interval drops to 15 minutes or less. The OS adapts the interval based on the current network type to minimize both radio wakeups and connection drops.
A common design review mistake: saying "the app opens a WebSocket to our server for push notifications." On mobile, the OS kills background WebSocket connections to save battery. The only reliable way to reach a phone is through the platform push service (APNs/FCM). WebSockets work for web browsers, not for mobile push.
Token Management and Device Registration
The device token is the address your server uses to reach a specific device. Getting it right is surprisingly complex, because tokens change and expire.
When a user installs your app and grants notification permission, the app calls the OS registration API. The OS contacts the platform (APNs or FCM) and gets back a device token. This token is unique to the combination of device + app + environment (production vs sandbox).
Your app immediately sends this token to your backend, which stores it mapped to the user ID. When you want to send a notification to user Alice, you look up all her device tokens (she might have an iPhone and an iPad) and send to each one.
Here is where it gets tricky. Tokens are not permanent.
On iOS, APNs can rotate the device token at any time (typically on OS updates, app reinstalls, or restoring from backup). Your app must check for a new token on every launch and re-register if it changed. On Android, FCM tokens can also change, and the onTokenRefresh callback fires when this happens.
The silent killer is uninstalled apps. When a user uninstalls your app, the token becomes invalid, but nobody notifies your server. You find out only when you try to send a notification and the platform returns an error ("InvalidRegistration" from FCM, or the token appears in the APNs feedback service). Your server must handle these errors by removing the stale token.
The key point: mention token lifecycle explicitly. It shows you have built real notification systems, not just read about them.
A subtle point about token scope: APNs tokens are specific to the app AND the environment (sandbox vs production). A token generated in the development sandbox is completely different from the production token for the same app on the same device. This catches many developers during their first production launch, because all their stored tokens are sandbox tokens that silently fail in production.
The data model for your token store matters too. A well-designed token table looks like this:
| Column | Purpose |
|---|---|
user_id | Which user this device belongs to |
device_token | The platform-specific token (primary key) |
platform | "ios" or "android" |
app_version | The version that registered the token |
created_at | When the token was first seen |
last_refreshed_at | Last time the app confirmed this token |
last_sent_at | Last time you sent a notification to this token |
failure_count | Number of consecutive platform errors |
is_active | Whether this token should receive notifications |
The last_refreshed_at column is the most important one for hygiene. If a token has not been refreshed in 30 days, the app has not launched in 30 days, which means the user is likely churned or uninstalled.
One more detail that catches developers in production: a single user can have multiple tokens. Alice has an iPhone and an iPad, both running your app. That is two tokens for user_id = "alice." When you send a notification for a new chat message, you must send to both tokens. If Alice reads the message on her iPhone, you might want to cancel or update the notification on her iPad. APNs does not support notification recall (once sent, it cannot be unsent). Your app's background handler must check whether the message has been read and dismiss the local notification if so.
Topic-Based Routing and Broadcast
When you need to send a notification to millions of users (a breaking news alert, a flash sale announcement, a system maintenance warning), sending individual API calls for each device token is slow and expensive. Both platforms solve this with topic-based subscriptions.
On the device side, the app subscribes to topics during registration: messaging.subscribeToTopic("news"). On the server side, you publish once to the topic, and the platform handles the fan-out to all subscribed devices. One API call reaches millions of devices.
FCM supports arbitrary string topics. APNs supports a similar concept through "channels" in iOS 16+. For older iOS versions, the workaround was maintaining your own subscriber lists and batching individual sends.
The tradeoff with topics: you lose per-user personalization. Every subscriber gets the same payload. If you need to say "Hey Alice, your order is ready" to millions of users with different names and orders, you still need individual sends. Topics are for broadcast content that is identical for everyone.
A practical default: use topics for broadcast (marketing, news, system alerts) and individual sends for personalized content (messages, order updates, account activity). Most apps need both.
The key point: mentioning topic-based fan-out shows you have thought about scale. "For broadcast to millions, I would use FCM topics so one API call triggers platform-managed fan-out, instead of sending millions of individual requests from my server."
Delivery Guarantees and Failure Handling
The third deep dive is what happens when the notification cannot be delivered immediately. The phone is off, the user is in airplane mode, or the network is congested.
When your server sends a notification, the platform makes a routing decision. If the device is currently connected, the notification goes through immediately (under 500ms end-to-end). If the device is offline, the notification enters a platform-managed queue.
The queue respects the time-to-live (TTL) value you set. A breaking news notification might have a 1-hour TTL (stale after that). A chat message might have a 28-day TTL (deliver whenever the user comes back). If the device does not reconnect before the TTL expires, the notification is silently discarded.
The collapse mechanism is important for chatty apps. If your app sends "3 new messages," then "4 new messages," then "5 new messages" while the phone is offline, you do not want the user to see all three. Setting the same collapse_id tells the platform to keep only the latest notification with that ID. The user sees "5 new messages" once, not three stacked notifications.
APNs calls this the apns-collapse-id header. FCM uses the collapse_key parameter. Both work the same way: only one notification per collapse key is stored in the queue. New notifications with the same key replace older ones.
The interaction between TTL, collapse, and priority creates a powerful configuration matrix. A useful way to think about it for different notification types:
| Notification Type | TTL | Collapse | Priority | Reasoning |
|---|---|---|---|---|
| Direct message | 28 days | Per-conversation | High | User must see it, collapse avoids flood |
| Like on a post | 1 day | Per-post | Normal | Nice to know, not urgent, one per post is enough |
| Ride arriving | 5 min | Per-ride | High | Stale after 5 min, must wake phone immediately |
| Marketing promo | 4 hours | Per-campaign | Normal | Time-boxed, one per campaign, battery-friendly |
| Security alert | 7 days | None | High | Every alert matters, no collapsing, immediate delivery |
The key differentiator in an design review: mention collapse_id and TTL together. It shows you have worked with the actual platform APIs, not just the concept. "I set collapse_id per conversation thread and TTL based on whether the notification is time-sensitive or persistent."
Bottlenecks, failure modes, and operations
-
Silent notifications for data sync: Both platforms support "silent" or "data-only" notifications that wake the app in the background without showing anything to the user. These are used for inbox sync, content prefetch, or triggering a local database update. The catch: both iOS and Android heavily throttle silent notifications (iOS limits to ~2 per hour), so you cannot use them as a general-purpose messaging channel.
-
Notification grouping on the device: Even with collapse_id, the OS groups notifications by app on the lock screen. iOS 15+ supports "notification summaries" that batch non-urgent notifications and deliver them at scheduled times. Your server has no control over this grouping once the notification reaches the device. This means your carefully designed notification copy might show up in a summary as "12 notifications from YourApp" instead of the individual titles you wrote.
-
End-to-end encryption complications: WhatsApp and Signal send encrypted message content in push notifications, but APNs/FCM can see the payload (it is encrypted in transit, but the platform can read it). To keep message content private, these apps send a silent notification that says "you have a new message," and the app decrypts the content locally when it wakes up. This adds latency to the user experience.
-
Multi-device scenarios: A user logged into your app on their iPhone, iPad, and MacBook should not receive the same notification three times for the same event. Your server must decide whether to send to all devices (for an alarm), the most recently active device (for a message), or only devices not currently active (for a missed call). This is a server-side deduplication problem that requires tracking device activity state.
-
Platform quotas and throttling: FCM has no hard per-message quota but throttles apps that send too many notifications to offline devices. APNs can reject connections from servers that repeatedly send to invalid tokens. Understanding these limits is essential for high-volume notification systems. FCM also has "high priority" quotas: if your app sends too many high-priority messages that the user does not interact with, the platform may downgrade future messages to normal priority.
A useful mental model: think of the platform push service as a post office. Your server drops off the letter (notification payload) addressed to a mailbox (device token). The post office (APNs/FCM) delivers it. If the mailbox does not exist anymore (uninstalled app), the letter gets returned. If the recipient is on vacation (device offline), the post office holds it for a while (TTL). If you keep sending letters to nonexistent mailboxes, the post office flags your account for abuse (throttling).
Common mistakes and misconceptions
| Mistake | What they say | Why it is wrong | What to say instead |
|---|---|---|---|
| Direct connection myth | "Our server sends the notification directly to the phone" | Your server talks to APNs/FCM, never to the phone. The platform maintains the persistent connection. | "Our server sends to the platform API (APNs/FCM), which routes it over the platform's persistent connection to the device." |
| Ignoring token lifecycle | "We store the token at registration and send to it forever" | Tokens rotate on OS updates, app reinstalls, and periodically. Stale tokens cause delivery failures and throttling. | "Tokens refresh on every app launch. We clean up invalid tokens on platform error responses and audit after 30 days of inactivity." |
| No TTL | "We just send the notification and hope it arrives" | Without TTL, a user turning on their phone after 3 days gets a flood of stale notifications. | "Every notification has a TTL based on its type. Time-sensitive alerts expire in hours, persistent messages last up to 28 days." |
| Confusing high/normal priority | "We set all notifications to high priority for fast delivery" | Platforms throttle apps that abuse high priority. High priority wakes the phone from power-saving mode and drains battery. | "High priority only for direct user actions (messages, calls). Marketing and digest notifications use normal priority." |
| Forgetting platform differences | "Push notifications work the same on iOS and Android" | APNs and FCM have different payload formats, size limits (4KB for APNs, 4KB for FCM data message), token formats, and delivery semantics. | "I handle iOS and Android as separate pipelines. Different payload formats, different token lifecycles, different priority semantics." |
Another trap: engineers sometimes say "we will use Firebase for push notifications" as if Firebase is the only option. Firebase Cloud Messaging (FCM) is Google's service for Android (and cross-platform). APNs is Apple's service for iOS. You must integrate with both if you support both platforms. Firebase can act as a unified abstraction layer that talks to APNs for iOS devices, but under the hood, the notification still goes through APNs. Knowing this distinction shows production experience.
Practical checklist
- Bind every token to the correct user, application, environment, and device/session context.
- Expect token rotation, uninstall/reinstall, provider rejection, OS permission changes, and app upgrades.
- Keep payloads small and non-sensitive; fetch authoritative data from the appβs durable inbox or API.
- Choose expiry and collapse behavior per provider and notification type; do not assume APNs and FCM semantics are identical.
- Treat provider acceptance as submission success, not proof of delivery, display, or user engagement.
- Separate urgent user-visible alerts from silent background refresh, which can be throttled or deferred.
- Measure send latency, provider errors, invalid-token rate, open rate, and the age of the underlying domain event.
- Load-test fan-out and verify that retries cannot create duplicate business actions.
Test Your Understanding
Quick Recap
- Push notifications travel from your server to a platform intermediary (APNs for iOS, FCM for Android), which routes them over a persistent connection to the device, and your server never talks to the phone directly.
- The phone maintains a single OS-managed TLS connection to the platform, shared by all apps, with periodic heartbeats to keep NAT entries alive, which is why push works without destroying battery life.
- Device tokens are the address for reaching a specific device, and they change on OS updates, reinstalls, and periodically, so your server must handle refresh and cleanup on every app launch and on platform error responses.
- Offline notifications are queued by the platform and delivered when the device reconnects, subject to the TTL you set, and expired notifications are silently discarded.
- Collapse IDs prevent notification floods by replacing older queued notifications with newer ones that share the same ID, so the user sees one summary instead of twenty individual updates.
- High-priority notifications wake the phone from power-saving mode and should be reserved for direct user actions like messages and calls, because platforms throttle apps that abuse high priority.
- Silent notifications wake the app for background data sync without showing anything to the user, but both platforms throttle them heavily (iOS: ~2 per hour).
- For end-to-end encrypted apps, the push payload contains a trigger (not the actual content), and the app decrypts locally after waking, adding 200-500ms latency but preserving privacy.
Related Concepts
- WebSocket connection management: The persistent connection pattern used by APNs/FCM is analogous to WebSocket connections in web apps. Understanding connection lifecycle, heartbeats, NAT traversal, and reconnection applies to both systems.
- Message queue fan-out: Topic-based push notification delivery is a fan-out pattern similar to Kafka topic consumers or SNS subscriptions. The platform acts as the message broker, handling one-to-many delivery at scale.
- Token-based authentication: Device tokens in push notifications follow similar lifecycle patterns to OAuth tokens (generation, refresh, revocation, expiry). The management challenges (stale tokens, rotation, multi-device) are analogous.
- Rate limiting and throttling: Platform quota management for push notifications uses the same patterns as API rate limiting: token buckets, sliding windows, and exponential backoff on 429 responses.
- Exactly-once delivery: Push notifications offer at-most-once delivery (the platform makes one attempt per online connection, queues when offline, but does not retry indefinitely). Understanding this helps contrast with messaging systems that offer exactly-once or at-least-once guarantees, and explains why important user-facing events should be confirmed through in-app sync, not just push.