Step 10
Step 10 — Implementing Push Notifications
0 views
Step 10 — Implementing Push Notifications
Through step 9 you framed the skeleton of a data platform with PostgreSQL, Redis, and Kafka. This time you put one user-facing feature on top of it — push notifications. Sending a message while the app is closed has to go through the OS or browser push channel in the end, and the server becomes a small pipeline that stores tokens, sends, and cleans up failures. Redis sliding TTL and Kafka events reappear here.
1. Firebase setup — overview only
This is configuration you do once before writing code.
- Create a project in the Firebase console and register client apps (Android, iOS, Web).
- Issue a service account key (JSON). This is the server's credential to call FCM.
- This JSON is a secret. Do not put it in code or the repository — inject it via environment variables or a secret manager.
import { initializeApp, cert } from "firebase-admin/app";
import { getMessaging } from "firebase-admin/messaging";
initializeApp({
credential: cert(JSON.parse(process.env.FCM_SERVICE_ACCOUNT!)),
});
The firebase-admin SDK hides OAuth2 token refresh and HTTP v1 calls. The surface we deal with is just two things: "token + message."
2. Client token registration
Push starts at user consent. The client requests permission, and once allowed, the OS or browser issues a token. A token is unique per app and device.
// Client — get a token and register it with the server
const token = await getToken(messaging, { vapidKey });
await fetch("/api/notifications/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }),
});
The server stores this token bound to the user. The registration endpoint must be idempotent — when the same token comes again, it must not be loaded as a duplicate. When a token is refreshed by the OS, the client calls registration again, so the server can simply accept the incoming token as the latest value.
3. Server-side token storage — Redis sliding TTL
Where to keep tokens is the next decision. When one user uses a phone, a tablet, and a browser together, there are several tokens, so keep a bundle of tokens under a user key.
const key = `notif:token:${userId}`;
const TTL = 60 * 60 * 24 * 60; // 60 days
async function registerToken(userId: string, token: string) {
await redis.sadd(key, token); // Set — a collection of per-device tokens
await redis.expire(key, TTL); // extend TTL on every use = sliding
}
- Sliding TTL — extend the TTL again with
expireevery time a token is registered or used for a send. Tokens of users who have not opened the app for 60 days expire and disappear on their own. No separate cleanup batch is needed. - Dual storage in a DB — Redis is volatile. If you also load the same token into a durable table, you can recover even if Redis is wiped. Redis is the cache, the DB is the source.
CREATE TABLE IF NOT EXISTS device_tokens (
user_id TEXT NOT NULL,
token TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, token)
);
PRIMARY KEY (user_id, token) blocks duplicate rows for the same device token. The indexes from step 1 and the Redis roles from step 4 carry straight over.
4. Sending — and handling failure codes
Sending means pulling out all of a user's tokens and dispatching the message. The key lies in reading the result and handling it differently by failure code.
async function sendToUser(userId: string, title: string, body: string) {
const tokens = await redis.smembers(`notif:token:${userId}`);
if (tokens.length === 0) return;
const res = await getMessaging().sendEachForMulticast({
tokens, // up to 500 per call
notification: { title, body },
});
res.responses.forEach((r, idx) => {
if (r.success) return;
handleFailure(userId, tokens[idx], r.error);
});
}
Failure is not one branch.
| FCM error code | Handling |
|---|---|
UNREGISTERED |
App removed or expired → delete the token permanently |
INVALID_ARGUMENT |
Payload error → do not retry, fix the code |
QUOTA_EXCEEDED · UNAVAILABLE |
Transient failure → retry with exponential backoff |
function handleFailure(userId: string, token: string, error?: { code: string }) {
if (error?.code === "messaging/registration-token-not-registered") {
redis.srem(`notif:token:${userId}`, token); // clean up the dead token
db.query("DELETE FROM device_tokens WHERE token = $1", [token]);
return;
}
if (error?.code === "messaging/invalid-argument") {
log.error("payload error — do not retry", { token });
return; // retrying fails forever
}
enqueueRetry(userId, token); // transient → backoff retry
}
Mind the error.code value above. The firebase-admin Node SDK reports these errors as messaging/...-prefixed strings (e.g. messaging/registration-token-not-registered). Those differ from FCM's own error codes (UNREGISTERED and friends), so when working with the SDK you must compare against the messaging/-prefixed strings — error.code === "UNREGISTERED" would never match.
Leaving dead tokens around corrupts send statistics and wastes the call quota. Build a flow where dead tokens get cleaned up alongside every send.
5. One-time consumption — preventing duplicate sends
One-off notifications like signup welcome or a password-change notice must go out only once. To prevent an accident where retry logic or duplicate events pick up and send the same notification twice, consume (delete) the successfully sent token entry immediately.
async function sendOnce(userId: string, title: string, body: string) {
const key = `notif:onetime:${userId}:welcome`;
const claimed = await redis.set(key, "1", "NX", "EX", 86400);
if (!claimed) return; // someone already sent it — skip
await sendToUser(userId, title, body);
}
SET ... NX succeeds only when the key does not exist. The second call hits NX and skips the send. Recurring notifications (chat, news) keep their tokens without consuming them — because they must keep being sent.
6. Asynchronous sending — not blocking the request
Running a send to thousands of people synchronously in the middle of handling an HTTP request delays the response by exactly that much. Separate the send from the request.
HTTP request → enqueue the send job → respond 202 immediately
↓
worker pulls from the queue, multicasts, cleans up failed tokens
Here Kafka from step 6 enters naturally. Domain events like "order completed" or "comment posted" flow as topics, and a notification consumer receives those events and sends. Putting the send into a topic consumer fully separates it from the request path, and with the idempotent handling from step 7, even if the same event arrives twice the notification goes out only once.
At a small scale, @Async (Spring) or a background worker (Node) alone is enough, without a message queue. The request responds quickly with just "send accepted," and the actual transmission proceeds behind it.
Common pitfalls
- Service account key bundled in code — a secret. Inject via environment variables or a secret manager
- Not cleaning up dead tokens — ignoring
UNREGISTEREDcorrupts send statistics and wastes the call quota - Retrying every failure —
INVALID_ARGUMENTis a code problem. Retrying fails forever - Multicast over 500 —
sendEachForMulticasthas a 500-per-call limit. Slice beyond it - Not consuming one-off notifications — retries or duplicate events send the same notification twice.
SET NXfor once only - Synchronous sending on the request thread — a bulk send blocks the response. Separate into a queue or async
Closing thoughts
Push looks small, but token storage, sending, failure cleanup, and duplicate prevention are all tangled into a small pipeline. The Redis TTL, Kafka events, and idempotent handling you learned in this course become its very ingredients. This is the final step that shows how a single feature weaves together the platform's many pieces.
Next
- data/09-fcm-push
- data/06-kafka-when
🎉 You finished PostgreSQL in depth + Redis · Kafka
What's next? Pick another course below.