Back to Blog

How push notifications work: APNs and FCM explained

How push notifications work: APNs and FCM explained

A push notification looks simple from the outside: something happens on a server, and a second later a phone lights up. In between sit two delivery networks run by Apple and Google, and nothing reaches a phone without going through one of them.

  • APNs, the Apple Push Notification service, delivers every push to iPhone, iPad, Mac and Apple Watch.
  • FCM, Firebase Cloud Messaging, delivers to Android devices with Google Play services, and can also send to iOS by passing messages on to APNs.

This guide follows one notification from your server to the lock screen on both platforms, with the details that only show up once you run it in production. We run both pipelines behind Pocket Alert, so the examples come from what we actually ship.

The four parties

  1. Your server, which Apple calls the provider. It decides that something should be sent.
  2. The push service, APNs or FCM. It accepts the request and routes it.
  3. The device. The operating system keeps one long-lived, encrypted connection to Apple or Google and receives pushes for every app over it.
  4. The app. It registered for notifications and owns the token the push is addressed to.

The third point is why push is cheap on battery. The phone never polls your server. One connection, shared by every app, stays open, and the push service sends down it when there is something to deliver.

Step 1: the device gets a token

When an app registers for remote notifications, the OS hands it a token: an opaque address for "this app on this device". The app sends the token to your server, and your server stores it.

  • An APNs device token is unique to the app and the device. Apple says not to cache it in the app: it changes when a user restores from a backup, moves to a new device or reinstalls the OS.
  • An FCM registration token plays the same role on Android, and FCM refreshes it on its own schedule.

Tokens go stale all the time. Keeping your stored tokens current is a large part of running push.

Step 2: your server proves who it is

APNs

APNs speaks HTTP/2 over TLS 1.2 or later, at api.push.apple.com for production and api.sandbox.push.apple.com for development builds, on port 443 or 2197. There are two ways to authenticate:

  • Token-based. You create a .p8 signing key in your Apple developer account and sign a short JWT with ES256, carrying your Team ID and the key's ID. Apple wants that JWT refreshed no more than once every 20 minutes and no less than once every 60. Send an older one and you get 403 ExpiredProviderToken; refresh too often and you get 429 TooManyProviderTokenUpdates.
  • Certificate-based. One TLS client certificate per app, valid for a year, which then has to be renewed.

Token auth is the one to use. A single key covers all your apps and does not expire every year.

FCM

FCM's HTTP v1 API takes a short-lived OAuth 2.0 access token from a Google service account, with the firebase.messaging scope. The older "legacy" API with a static server key was deprecated in 2023 and shut down from July 2024, so code that still sends Authorization: key=... no longer works.

Step 3: the request

Sending to APNs

Each push is an HTTP/2 POST to /3/device/<device token>, with a few headers that change how it is handled:

curl --http2 \
  -H "authorization: bearer $APNS_JWT" \
  -H "apns-topic: com.example.alerts" \
  -H "apns-push-type: alert" \
  -H "apns-priority: 10" \
  -d '{"aps": {"alert": {"title": "Backup failed", "body": "db-01: pg_dump exited with code 1"}, "sound": "default", "interruption-level": "time-sensitive"}}' \
  "https://api.push.apple.com/3/device/$DEVICE_TOKEN"
  • apns-topic is your app's bundle ID.
  • apns-push-type says what kind of push this is: alert for a visible notification, background for a silent data push, and a few others.
  • apns-priority is 10 to deliver now, 5 to let the device's power state decide, or 1 to favour power and not wake the device.
  • apns-expiration sets how long APNs should keep trying. 0 means one attempt and no storage.
  • apns-collapse-id lets a newer notification replace an older one with the same id.

The payload is JSON with an aps dictionary, and it is capped at 4 KB (5 KB for VoIP).

Sending to FCM

FCM takes one JSON message per request:

curl -X POST "https://fcm.googleapis.com/v1/projects/$PROJECT_ID/messages:send" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{
    "message": {
      "token": "DEVICE_REGISTRATION_TOKEN",
      "notification": { "title": "Backup failed", "body": "db-01: pg_dump exited with code 1" },
      "android": { "priority": "high", "notification": { "channel_id": "alerts_high" } }
    }
  }'

A notification message is displayed by the system when the app is in the background. A data message is handed to the app to deal with. Either way the payload limit is 4,096 bytes.

Step 4: delivery, or the wait for it

If the phone is online, the push goes down the open connection and appears within moments. If it is not:

  • APNs stores the notification for up to 30 days, depending on apns-expiration, but keeps only one notification per app per device. Send five while the phone is in airplane mode and it will usually see the last one. Apple does not even guarantee it is the latest.
  • FCM keeps messages for their time to live, four weeks by default and 28 days at most. Collapsible messages replace each other, with up to four collapse keys per device.

Priority affects timing too. On iOS, priority 5 and 1 pushes can be grouped, throttled or dropped. On Android, normal priority messages may wait until the device leaves Doze, while high priority is delivered immediately. FCM also watches what you do with that privilege: high-priority messages that do not lead to a visible notification can be quietly downgraded to normal over time.

How loud it is: interruption levels and channels

Delivery is one thing. Whether the phone makes a sound is another, and the two platforms handle it differently.

iOS (15 and later) gives each notification an interruption level:

  • passive adds it to the list without lighting the screen or playing a sound.
  • active is the default.
  • time-sensitive can break through Focus and the notification summary. Users can turn this off per app.
  • critical always plays, even in Do Not Disturb and with the mute switch on. It requires an entitlement that Apple grants on request, after review.

Android (8 and later) puts every notification in a channel, and the channel decides the importance: sound, vibration, heads-up display, Do Not Disturb behaviour. The app creates channels, but once a channel exists its behaviour belongs to the user. The app cannot change it later.

That last rule has real consequences. When we made Pocket Alert's critical alerts ring through silent mode, the existing critical channel could not be switched to alarm audio after the fact. The fix was a second channel with a new id, and the server picks it only for app builds that create it, because sending to a channel the app never created means the notification is not shown at all.

On Android 13 and later there is one more gate: the app needs the POST_NOTIFICATIONS runtime permission, and on a fresh install notifications stay off until the user grants it.

What breaks in production

  • Stale tokens. APNs answers 410 Unregistered for a token that is no longer valid. Stop sending to it and delete it.
  • Wrong environment. A development build's token sent to the production host fails with 400 BadDeviceToken.
  • Oversized payloads. 413 PayloadTooLarge once you pass 4 KB. Long log lines in an alert body are the usual cause.
  • Downgraded priority. High-priority Android messages that never show anything lose their priority.
  • Notifications turned off. The push is delivered and the user never sees it, because the permission was declined or the channel muted.

The shortcut

All of this is worth building when the app is your product and you are sending to its users. For alerts to yourself or your team it is a lot of infrastructure: a developer account on each platform, signing keys, token storage, two request formats and two priority models.

A push notification service runs both pipelines for you. With Pocket Alert you send one request, with no tokens or certificates of your own:

curl -X POST https://api.pocketalert.app/v1/messages \
  -H "Token: $PA_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"title": "Backup failed", "message": "db-01: pg_dump exited with code 1", "level": "high"}'

The level field is translated for each platform:

  • silent is a passive notification with no sound on iPhone, and goes to a silent channel on Android.
  • low is passive on iPhone and low importance on Android.
  • default is a normal notification on both.
  • high is time-sensitive on iPhone and high priority on Android.
  • critical overrides Do Not Disturb on Android. On iPhone it arrives as time-sensitive, and the full critical-alert sound is rolling out as Apple grants the entitlement.

More on that in critical alerts, and on the API itself in what a push notification API is.

FAQ

Does APNs guarantee delivery?

No. APNs and FCM are both best-effort. APNs stores only the latest notification for an offline device, and neither service guarantees when a message arrives. Google's documentation goes further and says FCM should not be used for life-critical emergency alerts. For on-call paging, treat push as the fastest channel, not the only one.

Why do I need both APNs and FCM?

iPhones only accept pushes through APNs. Android devices with Google Play services receive them through FCM. If your users are on both platforms, your server talks to both, either directly or through FCM, which can forward to APNs for you.

Can FCM send notifications to an iPhone?

Yes. You upload your APNs authentication key to Firebase, and FCM delivers to iOS by passing the message on to APNs. Data messages to Apple devices have to be sent at normal priority.

How large can a push notification be?

4 KB for an APNs payload (5 KB for VoIP pushes), and 4,096 bytes for an FCM message. Long text belongs in your app or a link, not in the push.

What is a device token?

An address for one app on one device, issued by Apple or Google. Your server sends pushes to it. It can change at any time, so servers have to accept updated tokens and drop the ones APNs or FCM report as invalid.

Create a free Pocket Alert account to skip the keys and tokens, or see how other services compare in the alternatives.

Share this article