codingstairs
NotesEDULifeContact
⌕Search⌘K
koen

Navigation

  • Intro
  • Blog
  • Life

Get in touch

Send without signing in. Add your email if you'd like a reply.

  • Leave a message anonymously →
  • ✉ warragon112@gmail.com
  • KakaoTalk Open Chat ↗

© 2026 codingstairs

  • Notes
  • EDU
  • Search
  • Life
  • Contact
  • Legal
  • RSS
  • GitHub
EDU›PostgreSQL in depth + Redis · Kafka›Step 10

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 expire every 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 UNREGISTERED corrupts send statistics and wastes the call quota
  • Retrying every failure — INVALID_ARGUMENT is a code problem. Retrying fails forever
  • Multicast over 500 — sendEachForMulticast has a 500-per-call limit. Slice beyond it
  • Not consuming one-off notifications — retries or duplicate events send the same notification twice. SET NX for 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

← Step 9

Step 9 — Kafka Topic Design

🎉 You finished PostgreSQL in depth + Redis · Kafka

What's next? Pick another course below.

Next: Building public-data crawlers →Browse all courses