Firebase Cloud Messaging and APNs

Updated 4 interview angles 6 min read source
On this page9
  1. The model: tokens, not phones
  2. The mechanism: authenticating as the sender
  3. Sending: notification, data, priority
  4. Errors are instructions
  5. Token hygiene
  6. Where it breaks in production
  7. The trade not taken: a vendor push service over FCM
  8. Related
  9. Interview angle

Firebase Cloud Messaging and APNs

Article.

Verified 2026-10

Verified 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

text
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:

python
# 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")
text
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 authorization header over HTTP/2. It must be reused, not minted per push: Apple rejects tokens refreshed too often and expires them after an hour. The .p8 key 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

python
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 notification block 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: 10 is immediate and 5 is power-friendly, and background (content-available) pushes are throttled.
  • iOS rewriting. mutable-content: 1 lets 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) or apns-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
python
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:

sql
DELETE FROM push_tokens
WHERE updated_at < now() - interval '60 days';   -- the app re-registers on next launch

Where it breaks in production

  • Dead tokens accumulate. Ignoring UNREGISTERED means 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 .p8 key 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.

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 .p8 key does not expire.