Support

Lesson 4 of 11 ·11 min read

Push Notifications End to End

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

After this lesson you can explain

  • What happens between an event on your backend and a notification on the lock screen, request by request.
  • Why FCM and APNs are unavoidable middlemen, and why delivery is best-effort rather than guaranteed.
  • Where the push payload ends and your own API begins (notify then fetch), and why token hygiene separates production push from tutorial push.

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

Your backend            FCM / APNs                 Device OS              Your app
     |                       |                          |                     |
     |                       |     (one persistent      |                     |
     |                       |<===  connection, all  ===|                     |
     |                       |      apps share it)      |                     |
     |                       |                          |                     |
     |                       |<-- 1 app requests token--|<-- on startup ------|
     |                       |--- device token -------->|-------------------->|
     |<-------- 2 POST /devices { token } -------------------------------------|
     |   store token against user                       |                     |
     |                       |                          |                     |
     |   (order ships)       |                          |                     |
     |-- 3 POST /messages:send { token, payload } ----->|                     |
     |<-- accepted (or UNREGISTERED → prune) --|        |                     |
     |                       |-- 4 push down the pipe -->|                    |
     |                       |    (held + retried if     |                    |
     |                       |     device offline)       |                    |
     |                       |                          |-- 5 route by app -->|
     |                       |                          |   display (notification
     |                       |                          |   msg) or wake app
     |                       |                          |   (data msg)        |
     |<-------- 6 app fetches details over your API ---------------------------|

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.
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).

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: FCM accepts iOS tokens and forwards to APNs on your behalf, 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 is woken briefly to process the payload (sync, badge, custom notification)
App killed The OS still displays it Delivery not guaranteed; the OS may drop it or defer until next launch
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 410); APNs has an equivalent. 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.

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.

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 (/topics/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
"Campaign error rate climbing month over month" Dead tokens never pruned; handle UNREGISTERED/410 responses
"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
"User switched phones and gets nothing" New install produced a new token that never reached your backend; re-POST the token on every app start, not just first run

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, like the ID-token verification), 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?
  • A campaign's error rate climbs month over month; which push-service response is the backend failing to act on?

Reviewed 4 August 2026.