What you'll learn
- Quick answer
- The Three Players in Every Push Notification
- Registration: How a Server Learns Who to Notify
- APNs: HTTP/2 With a Signed JWT, Not a Persistent Certificate
- FCM: The HTTP v1 API and Short-Lived OAuth Tokens
- Web Push: VAPID, the Push API, and a Service Worker
- The Full Round Trip, Start to Finish
- Why Push Is Best-Effort, and Tokens Go Stale
- FAQ
Quick Answer
A push notification is delivered through three hops: your app server sends a message with proof of identity to the platform's push service (Apple's APNs, Google's FCM, or a browser vendor's Web Push endpoint), that service holds a persistent connection to the device and forwards the message, and the OS or browser wakes the app or service worker to display it. Your server never talks to the device directly, which is also why push notifications aren't a guaranteed-delivery mechanism.
The Three Players in Every Push Notification
However the exact plumbing differs between iOS, Android, and the web, every push notification system has the same three participants. Your app server (your backend) is the one that decides a notification should be sent and knows who to send it to. A platform push service — Apple's APNs, Google's FCM, or a browser vendor's Web Push endpoint (Mozilla's, Google's, Microsoft's) — is a service you don't control, that maintains a persistent, low-power connection to every device running that platform. And the client — the app, or the browser's service worker — is what actually receives the delivered message and shows something to the user.
Your server never opens a connection directly to a phone. Phones move between Wi-Fi and cell towers, sleep their radios to save battery, and sit behind NATs that make an always-open inbound connection impractical at scale — so instead, every phone (or browser) keeps exactly one persistent connection open to its platform's push service, and your server's job is only to hand off a message to that service and let it deliver over the connection it already has. This is why a push notification system is really three separate integrations — one with APNs, one with FCM, one with each browser's Web Push endpoint — even though from the user's perspective it's "one notification."
Registration: How a Server Learns Who to Notify
Before any message can be sent, the client has to register for push and hand your server a way to reach it. On iOS, the app calls Apple's APIs to register for remote notifications and receives a device token — an opaque identifier tied to that specific app install on that specific device. On Android and other FCM-based platforms, the app SDK requests a registration token from FCM. On the web, a page calls registration.pushManager.subscribe() from its service worker, which returns a subscription object containing an endpoint URL and a couple of cryptographic keys.
All three are conceptually the same thing wearing different names: an address, specific to one app install on one device (or one browser profile, for the web), that your server has to store and later hand to the right push service. None of them are secrets in the sense of being unguessable forever, but all three should be treated carefully — none of them should end up in client-side logs or analytics, since anyone holding a valid token can, in principle, ask the platform's push service to deliver a message through it if they can also authenticate as your app server.
APNs: HTTP/2 With a Signed JWT, Not a Persistent Certificate
Apple's current APNs Provider API runs over HTTP/2, and your server authenticates with a JSON Web Token rather than the older long-lived TLS client certificate. You generate one signing key in your Apple Developer account, and every request includes a JWT signed with the ES256 algorithm (elliptic curve, P-256, SHA-256) containing your team ID and key ID. Apple requires the token to be freshly issued — the claimed issue time has to be within roughly the last hour — so servers typically cache and reuse one JWT for under an hour rather than minting a new one per request.
One signing key authenticates requests for every app your team owns, and works against both the sandbox and production APNs environments, which is a real practical improvement over the old per-app, per-environment certificate model — no more separate certificates expiring on separate schedules for every app you ship. A request is a plain HTTP/2 POST to Apple's endpoint with the device token in the URL path, the JWT in the authorization header, and a JSON payload — the notification's content nested under an aps key — as the request body:
POST /3/device/<device-token> HTTP/2
Host: api.push.apple.com
authorization: bearer <signed-JWT>
apns-topic: com.yourcompany.yourapp
apns-priority: 10
{
"aps": {
"alert": { "title": "Order shipped", "body": "Your order #1092 is on its way." },
"sound": "default"
}
}
This is illustrative reference syntax matching Apple's documented Provider API shape, not something executed as part of writing this article — sending a real APNs push requires a paid Apple Developer account, a signing key, and a physical or simulator device registered for push, none of which are available in a backend code sandbox.
FCM: The HTTP v1 API and Short-Lived OAuth Tokens
Google's Firebase Cloud Messaging replaced its older legacy HTTP API (which used a single long-lived server key in the header) with the HTTP v1 API, which authenticates with a short-lived OAuth 2.0 access token instead. Your server holds a service account key (a JSON file downloaded once from the Firebase console), uses it to request an access token scoped to https://www.googleapis.com/auth/firebase.messaging, and that token — valid for about an hour — goes in the request's Authorization: Bearer header.
The request itself is a POST to a project-specific endpoint, https://fcm.googleapis.com/v1/projects/<project-id>/messages:send, with a JSON body describing the target (a registration token, a topic, or a condition) and the message content:
POST /v1/projects/your-project-id/messages:send HTTP/1.1
Host: fcm.googleapis.com
Authorization: Bearer <short-lived-oauth-token>
Content-Type: application/json
{
"message": {
"token": "<fcm-registration-token>",
"notification": {
"title": "Order shipped",
"body": "Your order #1092 is on its way."
}
}
}
The short-lived-token design is deliberately different from APNs' per-request JWT: FCM's access token is fetched once and reused for roughly an hour of requests, which matters at scale (you're not signing a fresh token per notification), while APNs' JWT is cheap enough to regenerate on the same rough schedule without needing a token-caching round trip to a separate auth server. This is illustrative reference syntax matching Google's current documented API, again not executed here — it requires a real Firebase project and service account credentials.
Web Push: VAPID, the Push API, and a Service Worker
Web Push is the odd one out in a good way: it's a genuine, vendor-neutral W3C standard, not a proprietary SDK, which is why the same subscription object works whether the push service on the other end happens to be run by Mozilla, Google, or Microsoft — your server code doesn't need to know or care which. The flow starts in the browser: a page registers a service worker, then calls pushManager.subscribe() with your site's public VAPID key (Voluntary Application Server Identification — an ECDSA key pair your server generates once, per site, not per user). The browser returns a subscription object with three pieces: an endpoint URL unique to that browser installation, and two keys, p256dh (a public key the browser generated, used to encrypt the payload so only that browser can decrypt it) and auth (an additional secret used in that encryption).
Your server stores that subscription object, then later sends a notification by POSTing an encrypted payload straight to the subscription's endpoint URL — no separate API key or project ID, because the VAPID key pair itself, used to sign a JWT included in the request, is what proves the request came from a server your site's owner controls:
POST <subscription.endpoint> HTTP/1.1
Authorization: vapid t=<signed-JWT>, k=<VAPID-public-key>
Content-Encoding: aes128gcm
TTL: 86400
<payload encrypted with the subscription's p256dh + auth keys>
On the receiving end, the browser decrypts the payload and wakes the page's service worker with a push event, even if the tab isn't open — the service worker's job is to call self.registration.showNotification(...) to actually display it. This illustrative flow matches the W3C Push API and Web Push protocol specifications and libraries like web-push for Node.js; it wasn't executed here since sending it for real requires a live HTTPS site, a registered service worker, and a subscribed browser.
The Full Round Trip, Start to Finish
Putting all three platforms into one sequence, the shape is identical even though the details differ: (1) the client registers with its platform and receives a token or subscription; (2) the client sends that token/subscription to your server, which stores it against a user or device record; (3) later, your server decides to send a notification and makes an authenticated request — a JWT-signed HTTP/2 call for APNs, an OAuth-bearer HTTP call for FCM, a VAPID-signed encrypted POST for Web Push — to the platform's push service; (4) the push service, which already holds an open connection to that device or browser, forwards the message over it; (5) the OS or browser receives it and either shows a system notification directly or wakes the app or service worker so it can decide what to display.
Your server is only ever a participant in steps 2 and 3. It never has, and structurally cannot have, a direct connection to the device — which is exactly why none of this works if a step in the middle silently fails, and why the next section matters as much as the happy path.
Why Push Is Best-Effort, and Tokens Go Stale
None of the three systems guarantee delivery. APNs and FCM will both silently drop a notification if the device has been offline long enough, and neither promises exact ordering if you send several in quick succession — the platform reserves the right to coalesce or drop notifications under memory or bandwidth pressure, which is documented behavior, not a bug in your integration. Treat push as "best-effort, usually fast," never as a guaranteed message queue; anything that must be reliably delivered belongs in your own in-app inbox or database as the source of truth, with push as a best-effort nudge pointing at it.
Tokens and subscriptions also expire or go invalid — a user uninstalls the app, clears site data, or the OS rotates the token during a routine refresh — and the platform's response to a send request against a dead token is your server's cue to delete it from your database. Apple used to run a separate "Feedback Service" for this; the current APNs Provider API instead returns a specific error status (unregistered token) directly in the send response, and FCM does the same. Skipping this cleanup step is one of the most common real-world push bugs: servers that keep retrying sends to tokens that have been dead for months, quietly wasting quota and occasionally tripping platform-side abuse detection for looking like spam.
