Knowledge Guide
HomeSystem DesignSystem Design Problems

medium Designing Notification Service

Let's design a multi-tenant notification service: one shared platform that many separate applications (tenants) — a social network, an e-commerce site, a productivity tool — use to deliver alerts to their users across email, SMS, mobile push, and in-app channels. Think Amazon SNS or Firebase Cloud Messaging: a single system that fans events out to the right channels, respects each user's preferences, and keeps every tenant's data isolated.

The design goal is a pipeline that accepts an event once and delivers it at least once per selected channel. "At least once" is a deliberate choice — it means duplicates are possible, so we suppress them at two distinct layers: (1) an idempotency key that collapses duplicate client submissions within a bounded time window, and (2) a delivery-time guard that stops a reprocessed queue message from calling a provider twice. These are different guarantees for different failure modes; the rest of the page keeps them separate on purpose.

Key terms

Requirements

Functional

Non-functional

Capacity estimation

One consistent set of numbers, used everywhere below.

QuantityAssumptionDerived
Tenants10–50 apps
Registered users100M
Daily-active users (DAU)25M
Notifications per active user/day~525M × 5 = ~125M/day
Average write rate125M ÷ 86,400s~1.5k writes/sec
Peak write rate~10–30× average~20–50k events/sec
In-app retentionlast ~100 per active user25M × 100 = ~2.5B records
Record size~500 bytes each2.5B × 500B = ~1.25 TB
With 3× replication1.25 TB × 3~3.75 TB (a few TB)

The storage figure now follows directly from the stated inputs: 25M active users × 100 retained notifications = 2.5 billion records, and at ~500 bytes each that is ~1.25 TB of primary data — roughly 3.75 TB once replicated three ways. (Retaining history for all 100M registered users instead of just actives would push this to ~10B records / ~5 TB; we scope storage to actives.) Preferences are one small row per registered user — 100M × a few hundred bytes ≈ tens of GB — and fit comfortably in a partitioned SQL or NoSQL store. Payload bandwidth at ~1 KB/notification is ~125 GB/day.

Takeaway: aggressive horizontal scaling, per-user data partitioning, and fully asynchronous processing.

diagram
diagram

API design

A small REST surface. The idempotency_key is the client's guard against submitting the same logical notification twice (e.g., its own retry after a network timeout).

1. Send notification

POST /v1/notifications

{
  "user_id": "u_12345",
  "template_id": "order_shipped_v1",
  "channels": ["push", "email"],
  "idempotency_key": "uuid-generated-by-client",
  "data": { "order_id": "999", "name": "Alice" }
}

Response 202 Accepted: { "notification_id": "notif_abc123" }. A retried submission with the same idempotency_key returns the same notification_id (see step 2). Error 429 on quota exceeded.

2. Get status

GET /v1/notifications/{notification_id}{ "status": "delivered", "channel": "sms", "updated_at": "..." }

3. Update preferences

PUT /v1/users/{user_id}/preferences → body { "categories": { "marketing": false, "shipping": true } }

High-level design and data flow

The system is an event-driven pipeline that decouples generating a notification from delivering it via a durable queue (Kafka). Each stage scales horizontally. Here is a single request's journey — the two dedup layers appear at steps 2 and 8.

  1. Request ingestion. The producer calls POST /v1/notifications. The API Gateway authenticates the tenant, applies per-tenant rate limits, and forwards to a Notification Service instance.
  2. Idempotency check (Layer 1 — client-submission dedup). The service mints a candidate notification_id and runs an atomic set-if-absent that stores the id as the value:
    SET  {tenant_id}:{idempotency_key}  {notification_id}  NX  EX 86400
    If the SET NX succeeds, this is a first-seen request — the service proceeds with that notification_id. If it fails (key already exists), this is a duplicate client submission — the service does a GET {tenant_id}:{idempotency_key} to read the previously assigned id and returns 202 with that same notification_id, doing no further work. A bare SETNX flag (a boolean sentinel) could not return the prior id; storing the id as the value is exactly what makes the dedup path able to return it. Limitation: this guard only holds within the TTL (24h here). A client retry after the key expires would be treated as new and mint a second notification — so the TTL must be set at least as long as the client's maximum retry horizon, and this bounded window is an accepted limitation, not global exactly-once.
  3. Preference & routing. The service reads the user's preferences (cached in Redis) to drop opted-out channels/categories, apply quiet hours, and enforce per-user caps (e.g., ≤ N marketing/day).
  4. Message preparation. For each surviving channel it renders the template into a channel-specific payload (email subject+HTML, short SMS text, push title/body).
  5. Enqueue. One message per channel is produced to that channel's Kafka topic, partitioned by user_id so a user's messages on a channel stay ordered. Each message carries notification_id, channel, and an attempt count.
  6. Scheduled path. If a future send_time is set, the request is stored by the Scheduler instead; when due, the Scheduler enqueues it into the same topics (rejoining at step 5).
  7. Consumption. Stateless channel workers consume their topic's partitions; parallelism is bounded by partition count.
  8. Worker delivery (Layer 2 — provider-call dedup). Kafka is at-least-once: if a worker sends to the provider and then crashes before committing its offset, the message is redelivered and — with no guard — the provider is called twice, producing a duplicate SMS/email. Layer 1 does not cover this: the idempotency key deduplicates client submissions, not repeated provider calls for the same already-accepted notification. So before every provider call the worker runs a delivery-time guard keyed by (notification_id, channel):
    SET  sent:{notification_id}:{channel}  1  NX      # only the winner proceeds
    Only the worker that wins the NX calls the provider; a redelivered copy sees the marker and skips straight to committing its offset. (Equivalently, a unique-constraint insert or conditional write in the status store gives the same guarantee durably.) Where the provider supports it, the worker also passes a provider-side idempotency key as defense in depth. This closes the double-send gap that Layer 1 alone leaves open.
  9. Confirmation & logging. The worker records the outcome (sent/delivered/failed + provider response) in the status store; transient failures are re-queued with backoff, and terminal failures land in a DLQ.
  10. Read path. The client fetches the in-app inbox from a separate query service / read replica, served from cache where possible, isolating reads from the write path.

No channel blocks another — each is delivered independently, so an email-provider lag never holds up push or SMS.

Data storage and schema

A hybrid store: SQL for structured, slowly-changing, relational data needing ACID (tenants, users, preferences, templates, scheduled jobs, audit logs); NoSQL (Cassandra/MongoDB) for the high-volume notification history and status, denormalized for join-free reads and horizontal scale.

SQL (selected)

NoSQL (high-scale)

A dead-letter storefailed_notifications(notification_id, user_id, channel, error_code, last_attempt_time) or a queue DLQ — holds messages that exhausted retries for later inspection or reprocessing.

Detailed component design

Scheduler & batch

Scheduled sends live in scheduled_notifications; a fault-tolerant scheduler (leader-elected or partitioned by time bucket) scans for scheduled_time <= now() and enqueues due items, pre-staging large batches slightly early for on-time delivery. Big campaigns (millions of users) use a bulk-load path (upload a user list to object storage, expand into per-user messages) rather than millions of API calls, with worker throughput scaled up and provider rate limits respected.

Workers & queue semantics

Workers are stateless consumers, one thread per partition; add consumers up to the partition count to scale. Kafka commits offsets after processing, giving at-least-once. Partitioning by user_id preserves per-user ordering within a channel ("Order Placed" before "Order Shipped").

Two-layer deduplication (summary)

Neither layer alone is sufficient: Layer 1 does nothing about queue reprocessing of an already-accepted notification, and Layer 2 does nothing about a client submitting the same event twice. Together they bound duplicates end-to-end without paying for full exactly-once.

Retry & failure handling

Workers classify errors: transient (timeouts, throttling) are retried with exponential backoff via a retry topic / delayed re-enqueue up to a max attempt count; permanent (invalid address, unregistered device token) are logged and dropped without retry. After max attempts a message goes to the DLQ. Critical channels can fail over to a secondary provider — and because the Layer-2 guard is keyed by (notification_id, channel), only one provider attempt can ultimately win, so fallback does not cause a duplicate.

Other

Per-user/channel rate limits to prevent spam; templating + localization; API auth (API keys/OAuth) so only authorized backends can send; content validation to prevent injection; graceful degradation (shed or defer load, buffer logs) under overload.

Scalability & performance

Designing for ~125M notifications/day with ~20–50k/sec peaks:

Sources

Adapted and corrected from the original "Designing a Notification Service" problem in this guide, with the capacity math reconciled to the stated inputs (25M DAU × 100 retained ⇒ ~2.5B records / ~1.25 TB), the idempotency mechanism specified as SET key value NX EX (value = notification_id) rather than a bare SETNX flag, and delivery-time (provider-call) deduplication added at the worker to close the at-least-once double-send gap. Supporting references: Apache Kafka documentation on consumer offset commits and at-least-once delivery semantics; Redis SET command documentation (NX/EX options); Amazon SNS and Firebase Cloud Messaging service documentation for multi-tenant push/fan-out patterns.

🔨 Practice this hands-on — Design a Notification Service →
Attempt it from an empty file, break it to feel the failure, then defend it under pushback.
🤖 Don't fully get this? Learn it with Claude

Stuck on Designing Notification Service? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.

🪜 Hint ladder (no spoilers)

Progressively stronger hints — you still solve it.

I'm working on the problem **Designing Notification Service** (System Design). Give me a HINT LADDER: start with the tiniest nudge, then wait. Only reveal the next, stronger hint when I ask. Do NOT show the full solution unless I type 'show solution'. Keep me doing the thinking. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🎨 Explain the approach visually

See the technique, not just code.

Explain the optimal approach to **Designing Notification Service** with a VISUAL walkthrough: trace it on a small concrete example using ASCII art / a step-by-step diagram, narrate what changes each step, then give time & space complexity with a one-line derivation. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🔍 Review my solution

Catch bugs, edge cases, sub-optimality.

I'll paste my solution to **Designing Notification Service**. Review it for correctness, missed edge cases, and time/space complexity, then coach me toward the optimal — don't just rewrite it. Ask me to paste my code now. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🔁 Drill the pattern

Lock in recognition with look-alikes.

Give me 2 problems that use the SAME underlying pattern as **Designing Notification Service**. For each, let me attempt first, then review my answer and name the trigger signal that reveals the pattern. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.

📝 My notes