Firebase Cloud Messaging and APNs
Article.
Verified 2026-10Verified 2026-10-15: FCM HTTP v1 (the legacy API began shutting down on 2024-07-22), firebase-admin 7.x for Python, PyJWT 2.15.1 for the token runs.
A mobile app cannot keep a socket open in the background, so a server that wants to reach it hands the message to the platform’s push service: Firebase Cloud Messaging for Android (and optionally iOS and web), Apple Push Notification service for iOS. The interview question is what you actually send, how you authenticate, and why pushes silently stop arriving, because push is best effort by design and almost every failure is quiet.
The model: tokens, not phones
app ── registers ──► FCM / APNs ──► registration token
│
└─ sends the token to your backend (stored per device)
backend ── message + token ──► FCM / APNs ──► device,
when it can- A registration token identifies one app install on one device. It changes on reinstall, on restore to a new phone, and when the platform rotates it; a user has as many as they have devices.
- The push service holds the connection to the device and delivers when the device is reachable, subject to battery policy. Your server never talks to the phone.
- Delivery is best effort. No receipt comes back to your server by default; a push accepted by FCM can still arrive late or never.
The mechanism: authenticating as the sender
Both services want a short-lived signed credential rather than a static key, and both are JWTs, generated here locally with PyJWT 2.15.1:
# APNs token auth: ES256 JWT signed with the team's .p8 key, reused for up to an hour
apns = jwt.encode({"iss": "TEAM123456", "iat": now}, p8_key, algorithm="ES256",
headers={"kid": "ABC123"})
# FCM HTTP v1: a service-account JWT, exchanged at Google for a 1-hour access token
assertion = jwt.encode({"iss": SA_EMAIL,
"scope": "https://www.googleapis.com/auth/firebase.messaging",
"aud": "https://oauth2.googleapis.com/token",
"iat": now, "exp": now + 3600}, sa_key, algorithm="RS256")APNs provider token header: {'alg': 'ES256', 'kid': 'ABC123', 'typ': 'JWT'}
APNs provider token claims: {'iss': 'TEAM123456', 'iat': 1792079028}
FCM assertion scope: https://www.googleapis.com/auth/firebase.messaging
FCM assertion lifetime: 3600 s- APNs accepts the provider token directly in the
authorizationheader over HTTP/2. It must be reused, not minted per push: Apple rejects tokens refreshed too often and expires them after an hour. The.p8key does not expire, unlike the yearly certificate it replaces. - FCM HTTP v1 exchanges the assertion for an OAuth access token valid for about an hour; a leaked access token is useful for an hour, where the legacy server key it replaced was valid forever. firebase-admin does the exchange and caching for you.
Sending: notification, data, priority
from firebase_admin import messaging
message = messaging.Message(
token=registration_token,
data={"event_id": "$zzRm-btff", "room_id": "!abc:example.org", "unread": "3"},
android=messaging.AndroidConfig(priority="high", ttl=3600),
apns=messaging.APNSConfig(
headers={"apns-priority": "10", "apns-push-type": "alert"},
payload=messaging.APNSPayload(aps=messaging.Aps(mutable_content=True)),
),
)
messaging.send(message)- Notification vs data messages. A
notificationblock is displayed by the OS itself when the app is in the background: simple, but the app cannot change it. A data-only message is handed to the app, which decides what to show; it is how an end-to-end encrypted chat app fetches and decrypts the real message before displaying anything. Data values must be strings. - Priority. On Android, normal priority may wait while the device is in
Doze; high priority wakes it. FCM watches whether high-priority messages
lead to a visible notification and deprioritises apps that use them for
silent work. On iOS,
apns-priority: 10is immediate and5is power-friendly, and background (content-available) pushes are throttled. - iOS rewriting.
mutable-content: 1lets a Notification Service Extension run before display, with a few seconds to fetch and decrypt. - Payload size. Both services cap a message at about 4 KB; send IDs and let the app fetch the content.
- Collapsing. A
collapse_key(FCM) orapns-collapse-id(APNs) makes a newer message replace an undelivered older one with the same key, so a phone that was offline gets one “3 unread” push instead of fifty.
Errors are instructions
The send response tells you what to do with the token. The FCM v1 cases that matter:
| Error | HTTP | Do |
|---|---|---|
UNREGISTERED |
404 | delete the token: app uninstalled or token expired |
INVALID_ARGUMENT |
400 | payload valid? then the token is bad: delete it |
SENDER_ID_MISMATCH |
403 | the token belongs to another Firebase project |
QUOTA_EXCEEDED |
429 | back off, honour Retry-After |
UNAVAILABLE, INTERNAL |
503, 500 | retry with exponential backoff |
THIRD_PARTY_AUTH_ERROR |
401 | the APNs credentials configured in Firebase are wrong |
from firebase_admin import exceptions, messaging
def send(token: str, message: messaging.Message) -> None:
try:
messaging.send(message)
except messaging.UnregisteredError:
tokens.delete(token) # never send to it again
except messaging.QuotaExceededError:
raise RetryLater(seconds=60)
except exceptions.UnavailableError:
raise RetryLater(seconds=backoff())APNs answers the same way: 410 Unregistered and 400 BadDeviceToken mean
the token is dead. For many recipients, firebase-admin 7 sends one request per
message (send_each, send_each_for_multicast); the old send_all and
send_multicast batch calls were removed in 7.0.0.
Token hygiene
FCM’s guidance: refresh the token from the app about monthly, store a timestamp with each token, and treat a token not refreshed for a month as stale. Android tokens inactive for 270 days are expired by FCM itself; iOS tokens are not, so prune them yourself:
DELETE FROM push_tokens
WHERE updated_at < now() - interval '60 days'; -- the app re-registers on next launchWhere it breaks in production
- Dead tokens accumulate. Ignoring
UNREGISTEREDmeans pushing to every phone a user ever owned, wasting quota and inflating “sent” numbers. - The APNs certificate expired. All iOS pushes stop on one day while
Android carries on; token auth with a
.p8key avoids it. - The service account was disabled or rotated. Every send fails with an auth error; alert on the provider error rate, not just your service’s uptime.
- High priority abused. Silent high-priority pushes get the app deprioritised, and then the real chat notifications arrive late.
- Sandbox vs production APNs. A development build’s token sent to the
production endpoint is a
BadDeviceToken. - Content in the payload. A notification block carries the message text through Google and Apple; for private messaging send identifiers only.
- Expecting delivery. Push is not a queue: a phone off for a week gets at most the latest of collapsible messages. The source of truth is the server; push only says “come and look”.
The trade not taken: a vendor push service over FCM
Services such as OneSignal or a cloud provider’s notification service wrap FCM and APNs with segmentation, scheduling and analytics. For marketing pushes they are worth it.
For a product’s own transactional and chat notifications they add a third party that sees every payload and a second set of failure modes, while the hard parts (token hygiene, payload design, priority) stay yours. Talking to FCM and APNs directly, or through a self-hosted gateway such as Sygnal for Matrix, keeps the data path short.
Related
Interview angle 4
- “How does a server send a push to an Android phone?” - it sends a
message with the device’s registration token to FCM HTTP v1, authenticated
with a short-lived OAuth token from a service account. FCM delivers when the
device is reachable.
- Follow-up: why OAuth instead of the old server key? The access token lives about an hour, the legacy server key forever; the legacy API has been shutting down since July 2024.
- “Notification message or data message?” - notification messages are
shown by the OS in the background; data messages go to the app, which
decides. Private or encrypted content needs data messages with IDs only.
- Follow-up: why do your chat pushes arrive late on some Android phones? Normal priority waits in Doze, and an app that sends high priority without showing notifications gets deprioritised.
- “FCM returns UNREGISTERED. What now?” - delete that token. For
INVALID_ARGUMENT, check the payload first, then delete. - “All iOS pushes stopped today.” - an expired APNs certificate, or
sandbox and production tokens mixed. Token auth with a
.p8key does not expire.