Support

Lesson 4 of 11 ·11 min read ·Reviewed 6 August 2026

Push Notifications End to End

From your backend to the lock screen, request by request.

1. The problem push solves

Your backend cannot simply send a message to a phone. The phone has no public IP, sits behind NAT, and moves between networks all day. And if every app held its own network connection open waiting for messages, the battery would be dead by lunch.

The solution: the OS maintains one single persistent connection per device to its platform's push service, Google's FCM (Firebase Cloud Messaging) or Apple's APNs (Apple Push Notification service), and every app on the phone shares it. Your message rides that shared pipe. This is why push is OS infrastructure, not something an app can build itself, and why the platform services are unavoidable middlemen.

2. The full path at a glance

3. The flow, step by step

App → OS → Push service

Step 1: Registration. The app gets a device token

On startup the app asks the OS for a device token (FCM calls it a registration token): an opaque string meaning "this app, on this phone." Two separate concerns people conflate:

  • Getting the token needs no user permission. The pipe exists regardless.
  • Showing notifications needs the permission prompt (always on iOS; Android 13+ too). A user who declines can still receive silent data messages, within the delivery limits covered in step 5.
App → Your backend

Step 2: The token goes to your backend

Device registration request (sample)
POST /devices
Authorization: Bearer <your API's access token>

{ "token": "fMEQ4...", "platform": "android" }

Your backend stores it against the logged-in user. This is the step that makes push an API problem: the token row is the join between your user table and the push service. One user may have several live tokens (phone, tablet, the old phone in a drawer that is still signed in).

The join must also be unmade. On logout or account switch, the app tells the backend to delete or reassign the token row; skip that and the next person to sign in on a shared or handed-down phone receives the previous user's notifications. That is a privacy bug, and a common one.

Your backend → Push service

Step 3: Sending

Something happens server-side. Your backend looks up the user's tokens and calls the push service's API. FCM's HTTP v1:

FCM send request (sample)
POST https://fcm.googleapis.com/v1/projects/myapp/messages:send
Authorization: Bearer <service-account access token>
Content-Type: application/json

{
  "message": {
    "token": "fMEQ4...",
    "notification": { "title": "Order shipped", "body": "Arriving Thursday" },
    "data": { "order_id": "1234" },
    "android": { "ttl": "3600s", "collapse_key": "order_1234" }
  }
}

Auth is OAuth service-account credentials: a backend-to-backend grant, no user involved. APNs is the same idea in different dress: HTTP/2, auth via a JWT signed with a .p8 key downloaded from the Apple developer portal.

In practice most backends use FCM for both platforms: the FCM SDK in an iOS app registers through APNs and hands the app an ordinary FCM token, and FCM relays your sends to APNs behind the scenes, so you integrate one API instead of two.

Push service → Device

Step 4: Delivery down the shared pipe

FCM/APNs maps the token to the device's persistent connection and pushes the message down it. If the device is offline, the service holds the message and delivers on reconnect, governed by:

  • TTL: how long the message stays worth delivering. A "your driver is outside" push with a 10-minute TTL should die rather than arrive at midnight.
  • Collapse key: newer messages with the same key replace queued older ones, so a reconnecting device gets one "score update," not forty.

Delivery is best-effort, never guaranteed. Push is a doorbell, not a courier. Messages can be dropped, delayed, throttled by battery-saver modes, or silently discarded on devices with aggressive OEM battery managers. Anything important must also be fetchable from your API; the notification is only an invitation to come get it. If your app breaks when a push is missed, the design is wrong.

Device OS → App

Step 5: On the device. Two message types, three app states

The OS receives the message on the shared connection and routes it by app. What happens next is the most commonly misunderstood part of push, and it is a two-by-three grid:

Notification message
(notification block)
Data message
(data only)
App in foreground Handed to the app silently; the app decides whether to show anything (chat apps suppress the bell for the conversation you are looking at) Handed to the app silently
App in background The OS displays it itself; your code may not run at all until the user taps The app may be woken briefly to process the payload (sync, badge, custom notification); see the platform note below
App killed The OS still displays it Delivery not guaranteed; the OS may drop it or defer until next launch

The grid is the shared model; the platforms differ at the edges. Android hands data messages to your code subject to message priority and Doze, and a force-stopped app (swiped away is fine; a force stop from Settings is not) receives nothing until the user relaunches it. iOS treats a data-only message as a background push: the app must declare the remote-notification background capability, and the system decides if and when to run it, throttling freely. That discretion is why the best-effort callout above applies doubly to data messages.

Your backend

Step 6: Token hygiene. The part everyone skips

Tokens die constantly: reinstalls, OS updates, phone upgrades, restored backups. When you send to a dead token, FCM answers UNREGISTERED (HTTP 404); APNs answers 410 with the reason Unregistered and notes there is no need to keep sending to that token. Your backend must delete that token row on the spot. Skip this and the dead-token list grows forever: every campaign gets slower, error rates climb, and your delivery metrics become fiction. The loop is simple (send, read the per-token result, prune) and it is the difference between production push and tutorial push.

The client side of the same hygiene: apps must listen for token-refresh events (the OS can rotate a token at any time) and re-POST the new one to /devices. Make that upload idempotent, and stamp the row with a last-seen date each time. Firebase recommends refreshing the stamp about monthly (more often than weekly brings no benefit), treats a token unseen for a month as stale by default, and garbage-collects Android tokens after 270 days of inactivity. Dead tokens leave by error code; quiet ones leave by age.

4. Security: what goes in the payload

Nothing sensitive goes in a push payload. It transits Google's or Apple's servers in a form they can read, and it lands on lock screens where anyone glancing at the phone sees it. The standard pattern is notify then fetch: the payload carries an ID ("order_id": "1234"), and the app fetches the details over your authenticated API. Banking apps that show "You have a new message" instead of the message itself are doing exactly this. The size cap points the same way: FCM tops out at 4096 bytes of payload, so anything substantial has to be fetched, not pushed.

Two credentials to keep straight:

  • A device token is not exactly a secret (it is useless without your server credentials), but it is user-linked data; do not leak or log it casually.
  • Your service-account key / .p8 key is fully a secret: anyone holding it can push arbitrary notifications to your entire user base, which is a phishing machine. Same handling as any production API key.

5. Variations worth knowing

  • Silent push: a data-only message with no visible notification, used to trigger background sync ("new email exists, go fetch"). Both platforms throttle these hard (iOS especially), so they are a hint, not a scheduler.
  • Rich push: images, action buttons ("Reply", "Mark done"), and progress-style updates. On iOS this runs through a notification service extension, a tiny bit of your code that can rewrite the notification (e.g. download the image, or decrypt a payload) before display. End-to-end-encrypted messengers use exactly this hook: the push payload is ciphertext, decrypted on-device by the extension.
  • Topics and groups: instead of sending to tokens one by one, FCM lets devices subscribe to a topic (breaking-news) and you publish once. Good for broadcast; useless for per-user messages.
  • Web push: the same architecture exists for browsers: a service worker receives the message, the push services are run by the browser vendors, and your server authenticates with VAPID keys instead of a service account. Same token-registration, same hygiene problem, same best-effort delivery. This is what a PWA uses, and it closes the loop with the first lesson in the course: push is the capability that most often forces the native-vs-web decision.

6. Common gotchas

SymptomCause
"Works in foreground, nothing in background" Sending data-only messages and expecting the OS to display them; the OS only auto-displays notification messages
"Pushes stopped for some Android users" OEM battery managers (common on several Android brands) killing background delivery; partially mitigable, never fully fixable. A force-stopped app receives nothing until the user next opens it
"Campaign error rate climbing month over month" Dead tokens never pruned; handle FCM's UNREGISTERED (404) and APNs' 410 Unregistered
"User got 30 stacked notifications after a flight" No collapse key and no TTL on messages that supersede each other
"iOS silent pushes arrive sometimes" Working as designed; silent push is throttled and deliberately unreliable
"Notifications show stale content when tapped" Trusting the payload as the data instead of notify-then-fetch
"Ex-user still receives the account's notifications after logging out" Token row never deleted or reassigned on logout; unregister the device when the session ends
"User switched phones and gets nothing" New install produced a new token that never reached your backend; upload on every token-refresh event, and make the POST idempotent so re-sending on start is safe

The takeaway shape: push mirrors the structure of the login flow. A platform intermediary in the middle (FCM/APNs, like Google's OAuth servers), a registration handshake that ends with your backend storing a credential-like artefact against a user (the device token, the way login ends with your backend holding its own session for the user), and a steady state that is plain API work: authenticated sends, per-item error handling, and pruning. The mobile-specific mystery is thin; underneath, it is APIs all the way down.

Check your understanding

  • Why can your backend not deliver a message to a phone directly, and what does the OS provide instead?
  • Which message type does the OS display by itself when the app is in the background, and which one wakes your code?
  • Why does a production payload carry "order_id": "1234" but not the order details themselves?
  • A campaign's error rate climbs month over month; which push-service responses is the backend failing to act on?

Reviewed 6 August 2026.