Push Notifications — FCM and Web Push
Push Notifications — FCM and Web Push
To deliver messages on mobile or web while the app is closed, the push channels provided by the OS or the browser must be traversed. iOS has APNs, Android has FCM, and the web has the Web Push standard.
1. About FCM
FCM (Firebase Cloud Messaging) is Google's push messaging service. It is the successor to GCM (Google Cloud Messaging, 2012). The brand was unified to FCM in 2016, and the GCM API was retired in stages (2018 to 2024). The currently recommended API is HTTP v1 (the legacy server key API is deprecated).
FCM bundles the following channels.
- Android — its own channel.
- iOS — internally delivered via APNs.
- Web — runs on top of standard Web Push.
2. APNs and Web Push
APNs (Apple Push Notification service) — Apple's own channel (introduced in 2009). Delivers messages to iOS, iPadOS, macOS, watchOS, and tvOS. Authentication is token-based (JWT) or certificate-based.
Web Push — browser push standardized by W3C and IETF. RFC 8030 (HTTP Web Push, 2016), RFC 8291 (message encryption, 2017), RFC 8292 (VAPID, 2017). VAPID is the mechanism by which the sender proves its identity to the push service. Chrome, Firefox, Edge, and Safari support it (Safari from 16.4 / 2023 on macOS and iOS 16.4+).
3. Token lifecycle
① Request the client asks the user for push permission
② Issue OS or browser issues a token (or subscription). Unique per app and device.
③ Deliver the client registers the token with its backend
④ Use the backend submits the token plus message payload to the push service
⑤ Refresh OS may invalidate and reissue tokens at will
⑥ Expire when the user disables permission or removes the app, failures pile up even if the token is alive
Tokens do not last forever. A refresh and cleanup flow is needed.
4. Message types (FCM-based)
| Type | Behavior |
|---|---|
| notification | Auto-displayed in the system tray. Handled by the OS when the app is backgrounded. |
| data | Payload only. The app handles it directly. Background processing limits vary by OS. |
| combined (notification + data) | Both. Background goes to OS, foreground to the app. |
iOS background data-only messages are subject to additional flags like content-available: 1 and OS processing priority assumptions. Reliability is often reported as not guaranteed.
5. Payload and firebase-admin
FCM HTTP v1 payload example:
{
"message": {
"token": "<device_token>",
"notification": {
"title": "새 메시지",
"body": "확인해 주세요"
},
"data": {
"type": "chat",
"chat_id": "1234"
},
"android": { "priority": "high" },
"apns": {
"payload": {
"aps": { "sound": "default" }
}
}
}
}
Node, Python, Java, Go, and .NET SDKs are available.
import { initializeApp, cert } from 'firebase-admin/app';
import { getMessaging } from 'firebase-admin/messaging';
initializeApp({ credential: cert(serviceAccountJson) });
await getMessaging().send({
token,
notification: { title, body },
data: { type: 'chat', chat_id },
});
The service account JSON key is a secret. Manage it via environment variables or a secret manager. The SDK abstracts OAuth2 token refresh and HTTP v1 calls.
6. Self-hosted Web Push server
Generate VAPID keys (public/private pair), and the client receives a subscription via pushManager.subscribe(...). The server calls the push service (Firefox autopush, Chrome FCM endpoint) directly using libraries like web-push (Node) or pywebpush (Python). Payloads are encrypted with ECE (RFC 8188).
This path works without Firebase enrollment. iOS Web Push (16.4+) follows the same standard.
7. Comparison with OneSignal
OneSignal is a managed service that bundles push, email, and SMS (2014). It abstracts FCM, APNs, and Web Push under one SDK. Analytics, segmentation, and A/B are often cited as strengths. Limits are that user data lives externally and free tier caps.
| Item | FCM (direct) | OneSignal | Self-hosted Web Push |
|---|---|---|---|
| Mobile (iOS+Android) | possible (firebase-admin) | possible | iOS only on 16.4+ web |
| Web | possible (FCM JS SDK) | possible | possible (VAPID) |
| Analytics, segmentation | basic only | strong | implement yourself |
| Data ownership | through Google infra | through OneSignal | own + push service |
| Cost | usage-based (Google) | tiered | very low |
8. Token cleanup and topics
The server reads send results and cleans up failed tokens.
- FCM: errors like
messaging/registration-token-not-registered→ delete the token row. - Web Push:
410 Goneor404 Not Found→ delete the subscription.
FCM provides a topic subscription model (/topics/news). One call sends to all subscribers of the same topic. However, with many users, response delays on topics get reported. When precise distribution matters, batch and multicast with explicit token bundles is more common.
9. Server-side token storage and cleanup
The sending server must keep the tokens that clients register somewhere, so it can retrieve them at send time. The starting point of storage design is that a token is per device, not per user.
A common shape is a Redis entry keyed by the user identifier.
notif:token:<userId> → Set or Hash (a collection of per-device tokens)
- Sliding TTL — extend the TTL again every time a token is used or refreshed (e.g. 60 days). Tokens of users who have not opened the app for a while expire and disappear on their own. Inactive users are filtered out without a separate cleanup batch.
- One-time consumption after a successful send — for one-off notifications (signup welcome, password-change notice, etc.), delete the token entry immediately once the send succeeds. This prevents an accident where retry logic picks up and sends the same notification twice. Recurring notifications (chat, news) are kept, not consumed.
- Dual storage in a DB against Redis failure — Redis is fast but volatile. If tokens are also loaded into a durable table, you can recover from the DB even if Redis is wiped. Keep Redis as a lookup cache and the DB as the source of truth (SSOT).
Lookup flow: Redis HIT → use
Redis MISS → query DB → fill Redis → use
Token storage is not "set and forget" data. If the flow that deletes the old token and inserts the new one on refresh is missing, tokens pile up duplicated on one device and the same notification goes out twice.
10. Handling send failures by error code
The push service returns the send result as an error code. Retrying every failure the same way keeps hammering tokens that should not be touched, or discards sends that could have been saved. Branch by code.
| FCM error code | Meaning | Handling |
|---|---|---|
UNREGISTERED / registration-token-not-registered |
App removed or token expired. No longer valid | Delete the token permanently. Do not retry |
INVALID_ARGUMENT |
Payload format error (field name, size, type) | Do not retry. The same request keeps failing — the code must be fixed |
QUOTA_EXCEEDED |
Send quota exceeded | Retry with exponential backoff. Retry again with widening intervals |
UNAVAILABLE / INTERNAL |
Transient push-server failure | Retry with exponential backoff. Usually recovers shortly |
SENDER_ID_MISMATCH |
Configuration error — the client app that minted the token and the server credentials point to different Firebase projects | Fix the Sender ID / service-account pairing first, then clean up tokens |
There are four core branches.
- Permanent failure → delete the token —
UNREGISTEREDis the representative case. Leaving dead tokens around corrupts send statistics and wastes the call quota. - Bad request → do not retry —
INVALID_ARGUMENTis a code problem, not a network problem. Retrying fails forever, so log it and stop. - Configuration error → check the environment first —
SENDER_ID_MISMATCHis a config problem, not a token problem. The client app and the server credentials (service account) point to different Firebase projects, so deleting tokens first only lets the re-registered tokens fail the same way again. Fix the Sender ID / service-account pairing first. - Transient failure → exponential backoff —
QUOTA_EXCEEDEDandUNAVAILABLEresolve with time. Widen the interval like 1s → 2s → 4s, retry only up to a cap, and give up beyond it.
Web Push follows the same principle. 404 and 410 Gone are permanent (delete the subscription); 429 and 5xx are transient (retry).
11. Multi-device and batch sending
When one user uses a phone, a tablet, and a PC browser together, one user has several tokens. Sending starts per user, but the actual transmission goes out to each of that user's tokens individually.
1 user → 3 tokens (phone · tablet · web)
→ 3 sends · 3 results too (each may differ per token)
Bulk sending uses multicast.
sendMulticast(orsendEach) — sends a bundle of tokens in one call. FCM has a limit of up to 500 tokens per call. For more than that, slice into chunks of 500 and call multiple times.- The response is a per-token array — sending 500 returns a result array with successes and failures mixed. Pick out only the tokens at failed indexes and apply the per-code handling of §10 (delete on permanent failure).
// Slice and send in chunks of 500
const CHUNK = 500;
for (let i = 0; i < tokens.length; i += CHUNK) {
const batch = tokens.slice(i, i + CHUNK);
const res = await getMessaging().sendEachForMulticast({
tokens: batch,
notification: { title, body },
});
res.responses.forEach((r, idx) => {
if (!r.success && isUnregistered(r.error)) {
deleteToken(batch[idx]); // clean up dead tokens
}
});
}
Sending must not occupy the request thread. Synchronously sending thousands of pushes in the middle of handling a user's HTTP request delays the response by exactly that much. Separate the send work into a queue (a message queue or job queue) or a separate async execution (e.g. Spring @Async, a background worker in Node). The request responds quickly with just "send accepted," and the actual transmission proceeds behind it.
HTTP request → enqueue the send job → respond 202 immediately
↓
worker pulls from the queue, multicasts, cleans up failed tokens
12. Common pitfalls
Using the legacy server key API — confirm migration to HTTP v1. The old GCM/FCM legacy API is a retired place.
Reliability of iOS data-only — background data-only messages are shaken by OS processing policies. Include a notification payload too when display is required.
Missing service worker — Web Push requires a registered service worker to receive messages. A service worker is needed even if it is not a PWA.
Lost VAPID keys — losing the keys for self-hosted Web Push invalidates existing subscriptions.
Notifications after app kill — some Android OEMs block background processing for force-killed apps, which may delay or drop notifications.
Payload size limits — both APNs and FCM have payload size limits (about 4KB). For larger data, send only a token and let the client call a separate API.
Timezone assumptions — naive sending that ignores the user's timezone results in midnight notifications.
Closing thoughts
Push hits its hardest step at user consent. Whether the first notification after consent feels valuable decides the opt-out rate. Late-night notifications and excessive frequency are places where permission slams shut at once.
Next
- image-pipeline
- backup-restore
References: FCM official docs, FCM HTTP v1, APNs official, RFC 8030 — HTTP Web Push, web-push npm, MDN Push API.