Skip to the solution
HardSystem Design17 min read

How would you design a notification system?

1.1k views

The problem

Question presented to candidate: "Many teams in our company want to notify users: order updates, login codes, marketing campaigns, reminders. Design one notification service they can all call that sends push notifications, emails and SMS, respects user preferences, and does not lose or duplicate important messages."

What a strong answer should cover:

  • Requirements split into transactional and marketing traffic, and a capacity estimate that includes campaign bursts, not just the daily average
  • The pipeline: an API that accepts requests with an idempotency key, preference and template resolution, per-channel queues with workers, third-party providers (APNs, FCM, an email service, an SMS gateway), and status tracking
  • Reliability: at-least-once delivery inside the system with deduplication, retries with exponential backoff, and a dead-letter queue; why exactly-once to a phone is not achievable
  • Respecting users: opt-outs per category and channel, quiet hours in the user's time zone, frequency caps, and priority isolation so a campaign never delays a login code
  • Channel facts that shape the design: push tokens expire, payload size limits, SMS segment lengths, bulk email sender rules

Clarifying questions expected:

  • Which channels, and which kinds of notification (transactional, marketing, both)?
  • What volume, and how big are campaigns compared to the daily average?
  • Which notifications are time-critical (login codes) and what latency do they need?

Code / implementation expected: Optional โ€” a pipeline simulation with dedup, preferences, quiet hours, caps and retries; the example runs one with a fake clock and failing providers.

  • notifications

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

17 min read ยท 17 sections

Read the code

Notification pipeline: idempotency keys, preferences, quiet hours, per-user caps, retries with backoff and a DLQ
// Notification pipeline: idempotency, preferences, quiet hours, per-user caps, per-channel retries and a DLQ.
console.log("== capacity estimate ==");
const users = 50000000, perUserPerDay = 10, campaignUsers = 50000000, campaignMinutes = 10, bytesPerRow = 1000, retentionDays = 30;
const perDay = users * perUserPerDay;
console.log("notifications/day: " + perDay + ", avg " + Math.ceil(perDay / 86400) + "/s, campaign burst " + Math.ceil(campaignUsers / (campaignMinutes * 60)) + "/s");
console.log("delivery log: " + (perDay * bytesPerRow) / 1000000000 + " GB/day, " + (perDay * bytesPerRow * retentionDays) / 1000000000000 + " TB for " + retentionDays + " days");

const prefs = {
  u1: { channels: ["push", "email"], marketing: true, quiet: [22, 7] },
  u2: { channels: ["email"], marketing: false, quiet: null },
  u3: { channels: ["sms", "push"], marketing: true, quiet: null },
};
const MARKETING_CAP = 2, MAX_ATTEMPTS = 4, BASE_BACKOFF = 30;
// Fake providers. push fails twice for u3 then works; sms always times out for u3.
const calls = new Map();
function provider(channel, user) {
  const key = channel + ":" + user;
  const n = (calls.get(key) || 0) + 1;
  calls.set(key, n);
  if (channel === "push" && user === "u3" && n <= 2) return "503";
  if (channel === "sms" && user === "u3") return "timeout";
  return "ok";
}
const fmt = (t) => "day" + (Math.floor(t / 86400) + 1) + " " + [Math.floor(t / 3600) % 24, Math.floor(t / 60) % 60, t % 60].map((x) => String(x).padStart(2, "0")).join(":");
const inQuiet = (q, t) => { if (!q) return false; const h = Math.floor(t / 3600) % 24; return q[0] > q[1] ? h >= q[0] || h < q[1] : h >= q[0] && h < q[1]; };

const tasks = []; let seq = 0;
const schedule = (t, task) => { tasks.push({ t, seq: seq++, ...task }); tasks.sort((a, b) => a.t - b.t || a.seq - b.seq); };
const T = (h, m) => h * 3600 + m * 60;
const events = [
  { t: T(21, 0), key: "ship-881", user: "u1", kind: "transactional", text: "order shipped" },
  { t: T(21, 0), key: "ship-881", user: "u1", kind: "transactional", text: "order shipped" }, // producer retry
  { t: T(21, 5), key: "promo-1", user: "u2", kind: "marketing", text: "sale" },
  { t: T(21, 10), key: "promo-2", user: "u3", kind: "marketing", text: "sale" },
  { t: T(23, 30), key: "otp-55", user: "u1", kind: "transactional", text: "login code" },
  { t: T(23, 40), key: "promo-3", user: "u1", kind: "marketing", text: "sale" },
  { t: T(23, 41), key: "promo-4", user: "u1", kind: "marketing", text: "sale" },
  { t: T(23, 42), key: "promo-5", user: "u1", kind: "marketing", text: "sale" },
];
for (const e of events) schedule(e.t, { type: "event", e });

const seen = new Set(), marketingSent = new Map();
const stats = { deduped: 0, optedOut: 0, deferred: 0, capped: 0, sent: 0, retries: 0, dlq: 0 };
while (tasks.length) {
  const task = tasks.shift(), now = task.t;
  if (task.type === "event") {
    const e = task.e, p = prefs[e.user];
    if (seen.has(e.key)) { stats.deduped++; console.log(fmt(now) + " " + e.key + " duplicate idempotency key, dropped"); continue; }
    seen.add(e.key);
    if (e.kind === "marketing" && !p.marketing) { stats.optedOut++; console.log(fmt(now) + " " + e.key + " " + e.user + " opted out of marketing"); continue; }
    if (e.kind === "marketing" && inQuiet(p.quiet, now)) {
      let wake = Math.floor(now / 86400) * 86400 + p.quiet[1] * 3600;
      if (wake <= now) wake += 86400;
      stats.deferred++; seen.delete(e.key);
      console.log(fmt(now) + " " + e.key + " quiet hours for " + e.user + ", deferred to " + fmt(wake));
      schedule(wake, { type: "event", e });
      continue;
    }
    if (e.kind === "marketing") {
      const n = marketingSent.get(e.user) || 0;
      if (n >= MARKETING_CAP) { stats.capped++; console.log(fmt(now) + " " + e.key + " " + e.user + " hit the marketing cap of " + MARKETING_CAP); continue; }
      marketingSent.set(e.user, n + 1);
    }
    for (const ch of p.channels) schedule(now, { type: "send", ch, e, attempt: 1 });
  } else {
    const { ch, e, attempt } = task;
    const result = provider(ch, e.user);
    if (result === "ok") { stats.sent++; console.log(fmt(now) + " " + ch + " -> " + e.user + " " + e.key + " " + e.text + " (attempt " + attempt + ")"); continue; }
    if (attempt >= MAX_ATTEMPTS) { stats.dlq++; console.log(fmt(now) + " " + ch + " -> " + e.user + " " + e.key + " failed " + result + " " + attempt + " times, sent to DLQ"); continue; }
    const wait = BASE_BACKOFF * 2 ** (attempt - 1);
    stats.retries++;
    console.log(fmt(now) + " " + ch + " -> " + e.user + " " + e.key + " failed " + result + ", retry in " + wait + "s");
    schedule(now + wait, { type: "send", ch, e, attempt: attempt + 1 });
  }
}
console.log("== summary ==");
console.log(Object.entries(stats).map(([k, v]) => k + " " + v).join(", "));

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 63 of 106 in System design

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

Up nextHard

How do you make a payment system reliable against retries?

Continue