12. Notification System Intermediate → Advanced

A. Realistic Interview Prompt

"Design a notification system — the kind of infrastructure sitting behind Uber, Amazon, or Slack — that lets any internal service tell a user something happened (an order shipped, a message arrived, a price dropped) and have that reach the user wherever they'll actually see it: a push notification, a text message, an email, or an in-app alert."

Nearly every problem in this document has, at some point, waved at "send the user a notification" as an unexamined side effect — News Feed's new-post event (P3), Chat System's offline-delivery fallback (P4), Ticketmaster's booking confirmation (P8), and Job Scheduler's own worked example of "a job that sends a best-effort notification email" (Problem 10, Deep Dive P). This problem is the system all of those were quietly assuming already exists. The honest, load-bearing answer underneath "send a notification" turns out to be one integrated decision: an async fan-out pipeline — ingest, dedupe, queue, and hand off to per-channel worker pools behind a pluggable provider adapter for push, SMS, email, and in-app — with a client-supplied idempotency key on every request, and per-user rate limiting plus digesting layered on top so one busy upstream service never turns into a wall of pings on someone's phone.

B. Difficulty

Intermediate, growing distributed-systems-heavy in the deep dives — the "accept a request, put it on a queue" shape is already familiar from half this document's async-offload examples (Patterns Playbook C); what makes this problem genuinely hard is the fan-out surface itself: one logical event can need to become zero, one, or several physical delivery attempts across entirely different external systems, each with its own delivery semantics, rate limits, and failure modes, all while still guaranteeing that no single event turns into a flood on any one user's device.

C. What This Problem Tests

D. Requirements

Functional

  1. Any internal service can submit a notification request specifying a target user, a notification type, and payload data for the message content
  2. The system resolves which channel(s) — push, SMS, email, in-app — a notification is delivered through, honoring an explicit override from the caller when one is supplied
  3. Every request carries a client-supplied idempotency key; resubmitting the same key within a bounded window results in exactly one delivery attempt, not two
  4. Users can view and update, per notification type and per channel, which channels they're opted into or out of
  5. Per-channel delivery status for a given notification (queued, sent, delivered, failed) is queryable after the fact

Non-Functional

Out of scope: this system is not itself a real-time messaging/chat product — Chat System (P4) already owns person-to-person messaging; rich template-authoring/CMS tooling; a full engagement-analytics dashboard (acknowledged as a real extension in AA); owning the business logic behind what "an order shipped" means — this system only owns getting a notification about that event delivered.

E. Clarifying Questions

#QuestionWhy It MattersDesign Decision Affected
1Is all notification traffic equally urgent, or is there a real mix of transactional (OTP, security alerts, payment confirmations) and best-effort (marketing, engagement) traffic?Determines whether one uniform pipeline suffices or priority-differentiated queues and SLAs are required from the startDeep Dive 7 (S), Capacity Estimation (G)
2Does the calling service specify the exact channel(s) to use, or does the system decide based on the user's own preferences?Changes where channel-routing logic actually lives, and who's responsible for respecting a user's stated preferencesAPI Design (I), Deep Dive 3 (O)
3Is exact-duplicate suppression for the identical triggering event required, or is the bigger concern overall notification volume regardless of duplication?Separates deduplication (a correctness problem, P) from rate limiting/digesting (a UX and volume problem, Q) — two different problems that are easy to conflateDeep Dive 4 (P), Deep Dive 5 (Q)
4What does "delivered" need to mean — handed off successfully to the channel provider, or confirmed opened/read by the user?Sets the scope of delivery-receipt tracking and how deep into each provider's callback/webhook mechanics the design needs to goCore Entities (H), Deep Dive 2 (N)
5Can one logical event legitimately fan out across more than one channel for the same user (push and email for a security alert), or is it always exactly one channel per notification?Changes whether "channel" is a single resolved value or a set resolved independently per notificationAPI Design (I), Deep Dive 3 (O)

F. Assumptions

G. Capacity Estimation

MetricValue / CalculationWhy It Matters
Total registered usersIllustratively, 100MSets the outer bound on how many distinct preference records and dedup/rate-limit counters the system needs to be able to hold state for
Baseline daily notification volumeIllustratively, ~500M notifications/day, transactional and best-effort combined500M / 86,400s ≈ ~5,800/sec average — a modest steady-state number that badly understates the real design pressure, which is the burst below
Peak fan-out burstA single triggering event (a flash sale, an incident alert) reaching 10M recipients within a few minutes ≈ 30,000–50,000 notifications/sec sustained for that windowThe queue (M) must absorb a peak-to-average ratio of roughly 5–10x without the ingest API itself slowing down — this is fundamentally a burst-absorption problem, not a steady-throughput one
Channel splitIllustratively, 60% push, 20% email, 15% in-app, 5% SMSSMS is priced per-message and reserved disproportionately for high-value/critical traffic (S); push dominates volume precisely because it's the cheapest channel to send at scale
Average request payload~1–2 KB (template ID, idempotency key, a small dynamic payload)Confirms the ingest path is throughput-bound, not bandwidth-bound — the hard part is request rate, not request size

H. Core Entities

EntityPurposeKey Fields
NotificationRequestOne logical "something happened, tell the user" event, as submitted by a calling servicenotification_id, idempotency_key (P), user_id, type, priority (S), payload, requested_channels (nullable override), created_at
TemplatePer-type, per-channel content template that a request's payload is rendered intotemplate_id, type, channel, body_template, version
UserPreferenceOne user's opt-in/opt-out state for one notification type on one channeluser_id, type, channel, opted_in, quiet_hours (O)
DeliveryAttemptOne concrete attempt to deliver one notification over one channel via one providerattempt_id, notification_id, channel, provider (N), status, attempted_at, error_code (R)
DigestBatchA window of accumulated low-priority notifications for one user/type, pending a single consolidated sendbatch_id, user_id, type, window_start, window_end, member_notification_ids (Q)

I. API Design

-- caller-facing (any internal service):
POST /notifications
  { idempotency_key, user_id, type, payload, priority, channels? (optional override) }
  -> { notification_id, status: "queued" }
GET  /notifications/{id}/status          -> per-channel DeliveryAttempt status list (H)
GET  /users/{id}/preferences             -> current per-type, per-channel opt-in state (O)
PUT  /users/{id}/preferences             -> update opt-in state
-- internal, cluster-facing, not caller-facing:
(ingest) validate + dedup (P) + persist request -> enqueue onto priority-appropriate queue (S)
(channel worker pools) consume queue -> resolve provider adapter (N) -> call provider -> record DeliveryAttempt
(digest scheduler) periodically flush an open DigestBatch (Q) into one consolidated send

Why POST /notifications returns "queued" immediately rather than waiting for the actual send: the caller only needs confirmation that the request was accepted and will be handled — waiting for an actual push/SMS/email round trip before responding would tie the caller's request latency to a downstream provider's latency, directly undermining the fast, non-blocking ingest path this system exists to provide (Patterns Playbook C). A caller that genuinely needs to know the outcome polls GET /notifications/{id}/status, or subscribes to a webhook/callback — never by blocking the original submission.

J. Basic / Naive High-Level Design (V1)

The simplest thing that satisfies the functional requirement in isolation: an API endpoint that, in the same request, directly calls a single hardcoded provider for whichever channel is requested.

flowchart LR Service["Calling Service"] --> API["Notification API"] API --> Provider["Push/SMS/Email Provider
(called synchronously, in-request)"] Provider --> API API --> Service

V1 — one synchronous call, straight from the request path, to one hardcoded provider per channel. No queue, no dedup.

Why this breaks immediately, not eventually: two separate, independent flaws, both fatal on their own. First, calling the channel provider synchronously inside the request path ties the calling service's own latency to the provider's — a slow or degraded push provider now makes every unrelated feature that happens to send a notification slow too, and a burst of triggering events (G) has nowhere to go but directly at the provider's own rate limit, with no buffer in between. Second, there is no deduplication at all: if the calling service's request times out and it retries — the standard, correct behavior for a client facing an ambiguous failure (Prerequisites O) — the notification is sent a second time, because nothing recorded that the first attempt might already have gone through. A user ends up with two identical push notifications for one event, and the system has no way to know or prevent it. Both flaws point at the same fix: move the send off the request path entirely, and give every request an idempotency key before it ever reaches a provider.

K. Data Model & Store Choice

Preferences and templates live in a relational database: both are comparatively low in volume relative to raw notification traffic, need simple, consistent key-based lookups ("what are this user's preferences," "what's the current template for this type"), and benefit from the referential structure a relational store provides — this is the same "match the store to the access pattern" reasoning from Prerequisites B and Distributed Cache's storage choice (P11-K), just landing on the opposite side of that decision here.

Delivery attempt logs live in a high-write-throughput, append-only store (a wide-column store in the Cassandra/DynamoDB mold), partitioned by notification_id or by user_id plus a time bucket — this is write-heavy, rarely-updated, mostly-append data at a volume (G) that would overwhelm a relational store's indexing and replication overhead if forced through the same engine as preferences.

Dedup keys and rate-limit counters live in Redis, keyed with a TTL matching their respective window: both need an atomic check-and-set at very high throughput (thousands of operations per second per hot user during a burst) and both are inherently time-bounded — a dedup window or a rate-limit window that never expires would leak memory forever. Redis's atomic SET ... NX EX primitive is exactly the "collapse read-decide-write into one operation" move named generally in Patterns Playbook B.

Digest batches are similarly short-lived, held in Redis (or an equivalently fast, TTL-capable store) rather than the durable delivery-log store — a batch's entire lifecycle is "accumulate for a bounded window, then flush and cease to exist," which is a poor fit for a store optimized for permanent, queryable history.

L. End-to-End Flow — A Backend Service Triggers a Notification

sequenceDiagram participant Svc as Calling Service participant API as Ingest API participant Dedup as Dedup Store (Redis, P) participant Q as Priority Queue (S) participant W as Channel Worker participant Pref as Preference Service (O) participant Ad as Provider Adapter (N) Svc->>API: POST /notifications (idempotency_key, user_id, type, payload) API->>Dedup: SET NX idempotency_key (P) alt key already exists Dedup-->>API: already processed API-->>Svc: original notification_id, status else key is new Dedup-->>API: claimed API->>Q: enqueue by priority tier (S) API-->>Svc: notification_id, status=queued Q->>W: deliver message (at-least-once, prereq-g) W->>Pref: resolve channel(s) + opt-in check (O) Pref-->>W: allowed channel(s) W->>Ad: send via channel-specific adapter (N) Ad-->>W: provider result W->>W: record DeliveryAttempt, retry if transient (R) end

Deduplication happens once, at ingest, before anything is queued — everything after that point is free to retry at the message-delivery layer without re-litigating "have we seen this before."

M. Deep Dive 1 — Async Fan-Out via Queue

Requirement this addresses: high, bursty throughput (D), and directly fixes V1's fatal synchronous-send flaw (J).

The ingest API's only job is to validate the request, run the dedup check (P), persist the NotificationRequest, and enqueue it — then return immediately. Everything downstream of that queue happens on its own schedule, at whatever rate the channel worker pools and the providers behind them can sustainably handle. This is the exact "keep expensive work off the hot path" move named generally in Patterns Playbook C — the fast, essential part (accepting and recording the request) is synchronous; the slow, non-essential-to-the-caller part (actually contacting a push/SMS/email provider) is deferred entirely.

Why a Kafka-style log, not just a traditional delete-on-consume queue (Prerequisites G), is the right default here: a single triggering event frequently needs to feed more than one independent downstream consumer at once — the push worker pool needs to send it, and an analytics/engagement pipeline may independently need to record that it happened, at its own pace, without either one blocking the other. This is precisely the scenario Prerequisites G names as the reason to reach for a log-based broker: "multiple entirely independent downstream systems can each read the full stream of events at their own pace." A traditional single-delivery queue would force an artificial choice about which one consumer gets the message.

What the queue actually buys under a burst (G): queue depth absorbs the gap between a 30,000–50,000/sec ingest spike and whatever steady rate the channel workers and their downstream providers can sustain — the burst is smoothed into a longer, steadier drain instead of being thrown directly at a provider's own rate limits, which would simply produce a wave of provider-side throttling errors instead of a smooth, if slightly delayed, delivery.

N. Deep Dive 2 — Multi-Channel Provider Abstraction

Requirement this addresses: the multi-channel functional requirement (D) — push, SMS, email, and in-app are not interchangeable; each has its own addressing scheme, payload format, and failure vocabulary.

ChannelRepresentative Real ProviderWhat the Adapter Normalizes
PushAPNs (iOS) / FCM (Android, web)Per-device registration token, per-platform payload formatting, and cleanup of tokens the provider reports as invalid/unregistered
SMSA Twilio-like carrier gatewayPhone-number validation/formatting, and reconciling delivery confirmation, which arrives as a webhook/callback rather than a synchronous response
EmailAn SES-like transactional email relayBounce and spam-complaint handling, and required unsubscribe headers (O)
In-appInternal service, no external vendorWrite to the in-app inbox store, and push over the user's existing connection if currently online — reusing Chat System's connection-routing pattern (Prerequisites K) rather than inventing a new one

The adapter pattern, concretely: each channel worker pool talks to one internal interface — send(notification) -> DeliveryResult — and a separate adapter implementation behind that interface handles the actual vendor-specific request/response shape. The worker pool itself never branches on "which vendor," only on "which channel," keeping the fan-out logic (M) and the provider-integration logic (N) cleanly separated — the same separation of concerns that lets Distributed Cache's storage engine stay ignorant of what a value actually contains (P11-K).

Provider failover within a single channel: a channel can be configured with a ranked list of provider adapters (a primary and one or more secondaries) rather than exactly one; when the primary's error rate or latency crosses a threshold, new sends for that channel route to the next provider in the list automatically, and traffic shifts back once the primary recovers. This bounds the blast radius of a single vendor outage to "degraded, temporarily slower delivery on one channel," rather than "that entire channel is down for every user."

O. Deep Dive 3 — User Preferences & Opt-Outs

Requirement this addresses: per-type, per-channel opt-in/opt-out (D) — and, for several channels, a genuine legal compliance obligation, not just a UX nicety.

The preference check happens before channel resolution, not after: a channel worker resolves which channel(s) a notification should go out on by first checking UserPreference (H) for that user and type; a channel the user has explicitly opted out of is simply removed from the candidate set, the same way it would be if the calling service had never requested it.

Compliance is a first-class part of this deep dive, not an afterthought: email delivery must include a functioning, one-click unsubscribe mechanism (the practical requirement behind CAN-SPAM compliance); SMS delivery must honor carrier-mandated opt-out keywords (a user texting STOP must actually stop receiving messages, a TCPA-driven requirement); both are enforced at the preference layer, not left to each calling service to reimplement independently.

Transactional notifications are the deliberate exception to marketing opt-out, not a bug: a payment confirmation, a security alert, or an OTP code is not subject to the same marketing-opt-out rules — a user who has opted out of promotional email has not opted out of being told their account was just accessed from a new device. The system distinguishes types that are legally/functionally transactional from those that are marketing at the template/type level (H), and the preference check for a transactional type only ever restricts channel choice, never whether the user is notified at all.

P. Deep Dive 4 — Deduplication & Idempotency

Requirement this addresses: exactly-once-feeling delivery for a repeated request (D), and directly fixes V1's second fatal flaw (J) — this is the identical mechanism generalized in Prerequisites O and named as its own pattern in Patterns Playbook E, here protecting a user from a duplicate push instead of protecting a customer from a duplicate charge.

The mechanism: the calling service attaches a stable, unique idempotency_key to every request (D). At ingest, the API performs an atomic SET key NX EX dedup_window against Redis (K) — if the key doesn't yet exist, this call both claims it and sets it in one atomic step, exactly the "collapse the check and the write into one operation" move from Patterns Playbook B; if the key already exists, the request is a duplicate, and the API simply returns the original notification_id and status instead of enqueueing anything new.

Why this single check is not the whole story: the queue itself (M) only guarantees at-least-once delivery of a message to a worker (Prerequisites G) — a worker can crash after calling a provider but before acknowledging the queue message, causing the same enqueued message to be redelivered and reprocessed. The ingest-time dedup check above already prevented a second request from becoming a second queued message, but it says nothing about a single queued message being processed twice. The fix is a second, narrower idempotency check at the worker layer, keyed by notification_id + channel, immediately before calling the provider adapter (N) — the same principle (a stable key, checked atomically before the side effect) applied a second time, against a different source of at-least-once delivery.

Choosing the dedup window: long enough to cover any realistic retry interval a calling service might use (minutes to low hours), short enough that the Redis key set doesn't grow unbounded — this is a tunable, not a fundamental constraint, and is called out explicitly as a configuration decision rather than a fixed constant.

Q. Deep Dive 5 — Rate Limiting & Digesting

Requirement this addresses: protecting a user from notification volume, not duplication (D) — a genuinely different problem from P, per E's clarifying question 3.

Per-user, per-channel rate limiting: the same token-bucket mechanism designed in full for Rate Limiter (P2) and named as a reusable cross-cutting concern in Patterns Playbook K, applied here per (user_id, channel) pair instead of per API client — each pair gets a bucket that refills at a configured rate, capping how many raw sends a single user can receive on a single channel in a short window, protecting them from a buggy or unexpectedly bursty upstream service (ten retried webhook deliveries for one shipment update, say).

Digesting — the better answer than simply dropping overflow: rather than silently discarding notifications a rate limit would otherwise reject, low-priority notifications in excess of the limit accumulate into an open DigestBatch (H) keyed by (user_id, type) over a bounded window (fifteen minutes, or hourly, depending on type); a background digest scheduler flushes the batch as a single consolidated notification once the window closes or a size threshold is hit — "5 people liked your post" instead of five separate, near-simultaneous pushes.

Critical/transactional notifications are exempt from both mechanisms entirely, by design: an OTP code delayed by a digest window, or dropped by a rate limit tuned for marketing traffic, is a functional failure, not a UX rough edge — priority (S) is checked before rate limiting or digesting is ever applied, and a critical notification bypasses both unconditionally.

The trade-off digesting makes explicitly: a digested notification's individual member is, by construction, visible to the user later than it would have been sent standalone — a deliberate latency cost, traded for a materially better experience than either a flood of near-duplicate pings or silent, unexplained drops.

R. Deep Dive 6 — Retry & Failure Handling

Requirement this addresses: resilience to a degraded provider (D) — a delivery attempt can fail for reasons ranging from a transient network blip to a permanently invalid destination, and the two need genuinely different handling.

Failure TypeExampleHandling
Retryable / transientProvider 5xx, timeout, temporary rate-limit responseExponential backoff with a bounded maximum attempt count — the same retry shape reused conceptually from Job Scheduler's Deep Dive P (P10-P), which named a best-effort notification email as its own worked example of exactly this trade-off
PermanentInvalid/unregistered push token, unsubscribed phone number, malformed email addressNo retry at all — retrying a guaranteed-permanent failure only wastes worker capacity and hammers the provider for no benefit; the attempt is marked failed immediately and surfaced

Dead-lettering after exhausting retries: a retryable attempt that fails every allowed attempt moves to a distinct "failed permanently" state in the delivery log (H), available for investigation, rather than being retried forever or silently dropped — the same dead-letter pattern common to message-queue systems generally.

Permanent failures feed a token-cleanup loop, not just a log entry: a push provider reporting a device token as invalid/unregistered is fed back to remove that token from the user's device record — otherwise every future notification to that user repeats the identical, guaranteed-to-fail attempt indefinitely, quietly wasting capacity on a channel that will never again succeed for that device until the app re-registers.

S. Deep Dive 7 — Priority & Delivery Guarantees

Requirement this addresses: low-latency delivery for critical notifications alongside best-effort delivery for everything else (D) — a single shared queue and worker pool cannot honor both at once, because a large marketing blast sitting ahead of an OTP in the same queue delays the OTP by however long the blast takes to drain.

Physically separate queues/topics per priority tier, each with its own worker pool: critical/transactional traffic and best-effort/marketing traffic are never colocated in the same queue, so a backlog on one tier has no mechanical way to delay the other. Each tier's worker pool is sized and alerted on independently — the critical tier's alerting fires on seconds of consumer lag; the best-effort tier's tolerates minutes.

Priority TierExamplesTarget SLAIsolation
Critical / transactionalOTP codes, security alerts, payment confirmationsLow seconds, end to endDedicated queue and worker pool; bypasses rate limiting and digesting (Q) unconditionally
StandardOrder-status updates, chat-offline fallback, activity alertsSeconds to low minutesIts own queue/pool; subject to per-user rate limiting (Q)
Best-effort / digestibleMarketing, engagement nudges, social activity summariesMinutes to an hour, by designFeeds the digest pipeline (Q); smallest, most elastic worker pool, scaled to absorb the largest bursts (G)

Why this belongs in its own deep dive rather than folded into M: M established that queuing decouples ingest from delivery generally; this decides that "one queue" is the wrong shape once traffic has genuinely different latency requirements — the same "not every pattern applies uniformly" lesson the Patterns Playbook draws explicitly about hot-path offloading (Patterns Playbook C's payment-flow exception), applied here to fan-out instead.

T. Full Solution at Scale — Final Architecture

flowchart TB Svc["Calling Services"] --> API["Ingest API
(validate + dedup, P)"] API --> Dedup[("Dedup Store
Redis, TTL'd, P")] API --> Log[("NotificationRequest + Preferences
Relational DB, K")] subgraph QUEUES [" Priority-Separated Queues (S) "] direction LR QCrit["Critical Queue"] QStd["Standard Queue"] QDigest["Digest-Eligible Queue"] end API --> QCrit API --> QStd API --> QDigest QCrit --> WCrit["Critical Worker Pool"] QStd --> WStd["Standard Worker Pool"] QDigest --> Digester["Digest Scheduler
(accumulate + flush, Q)"] Digester --> WStd WCrit --> Pref[("Preference Service, O")] WStd --> Pref Pref --> Adapters["Provider Adapter Layer, N"] Adapters --> Push["Push (APNs/FCM)"] Adapters --> SMS["SMS Gateway"] Adapters --> Email["Email Relay"] Adapters --> InApp["In-App Inbox"] WCrit --> DLog[("DeliveryAttempt Log
Wide-column store, K")] WStd --> DLog

Everything above is the composition of M through S — no new component introduced here that wasn't already justified by a specific deep dive.

U. Design Evolution

VersionWhat ChangedWhy
V1Synchronous, in-request call to one hardcoded provider per channel, no dedup (J)Simplest possible thing that satisfies the functional requirement in isolation
V2Async ingest-and-queue replaces the synchronous sendDecouples caller latency from provider latency, and absorbs bursts instead of throwing them directly at a provider (M)
V3Client-generated idempotency key with an atomic ingest-time dedup check addedFixes V1's duplicate-send flaw directly (P)
V4Provider adapter layer replaces a single hardcoded integration per channelNormalizes genuinely heterogeneous vendor APIs behind one internal interface, and enables provider failover (N)
V5Per-user preference checks added before channel resolutionRespects opt-outs and satisfies compliance obligations that didn't exist in the naive version at all (O)
V6Per-user rate limiting and digest batching layered on top of deliveryProtects users from volume, a distinct problem from the duplication P already solved (Q)
V7Priority-separated queues and worker pools replace one shared queuePrevents best-effort traffic from delaying critical/transactional delivery (S)
V8Retry classification (retryable vs. permanent) and dead-lettering added around provider callsStops wasted retries against guaranteed-permanent failures and surfaces genuine outages for investigation (R)

What stays stable throughout: the small POST /notifications contract from I — every later refinement changes how a request is routed, throttled, or delivered, never the basic shape of what a calling service submits.

V. Consistency Model

This system is, by deliberate design, eventually consistent almost everywhere, with one narrow strong-consistency island — the same shape named generally in Patterns Playbook J. The dedup check (P) is that island: it must be a single atomic operation against a store that guarantees a check-and-claim never race, because the entire correctness guarantee of "no duplicate delivery for one idempotency key" collapses the moment two concurrent requests can both observe "not yet claimed" before either writes its claim — exactly Pattern B's check-then-act race, resolved the same way. Everything downstream of that one check is eventually consistent by default and deliberately so: preference updates (O) may take a short, bounded amount of time to propagate to a worker's cached view; delivery-status visibility (H) lags slightly behind the actual provider call; a digest batch's contents (Q) are, by definition, not "final" until the window closes. None of this looseness threatens correctness, because the one guarantee users and calling services actually depend on — "you won't get this exact notification twice" — lives entirely inside the one strongly-consistent check, not smeared across the rest of the pipeline.

W. Failure Scenarios

X. Bottlenecks & Scaling Boundary

StageActual BottleneckFix Applied
V1 synchronous sendCaller latency is directly coupled to provider latency; no burst absorption at allAsync ingest-and-queue decouples the two entirely (M)
No dedup at ingestA caller's own retry, the correct client behavior under an ambiguous failure, produces a duplicate sendAtomic ingest-time idempotency check (P)
Shared queue across all prioritiesA large best-effort backlog delays time-sensitive critical notifications behind itPriority-separated queues and worker pools (S)
One hardcoded provider per channelA single vendor outage takes an entire channel down for every userRanked provider failover behind the adapter layer (N)
Uncapped per-user send volumeA single busy upstream service can flood one user with near-duplicate notificationsPer-user, per-channel rate limiting plus digest batching (Q)
Retrying every failed send identicallyPermanently invalid destinations (a dead push token) get retried forever for zero benefitRetryable/permanent failure classification and dead-lettering (R)

Y. Design Trade-Offs

DecisionOption AOption BTrade-off
Send pathSynchronous, in-requestAsync via queue (chosen)Synchronous is simpler to reason about but couples caller latency to provider latency and has no burst buffer; async adds a queue and eventual-status semantics in exchange for isolating callers entirely from provider slowness
Queue topologyOne shared queue for all prioritiesPriority-separated queues (chosen)One queue is simpler to operate, but a best-effort backlog can delay critical delivery; separate queues cost more operational surface area for a hard isolation guarantee
Overflow handling for rate-limited trafficDrop silentlyDigest and consolidate (chosen)Dropping is simplest and cheapest, but the user never learns what happened; digesting adds real scheduling complexity and a deliberate latency cost, in exchange for a materially better user experience
Provider integrationHardcoded, single provider per channelAdapter layer with ranked failover (chosen)Hardcoding is less code to write initially, but a single vendor outage takes down an entire channel; the adapter layer costs more upfront abstraction for real resilience to any one vendor's downtime
Retry policyRetry every failure identicallyClassify retryable vs. permanent (chosen)Retrying everything is simpler but wastes capacity retrying guaranteed-permanent failures forever; classification requires the provider adapter to surface a meaningful error type, in exchange for not hammering dead destinations

Z. Common Bad Designs

AA. Interviewer Follow-Ups

Follow-UpDesign ChangeComponents ChangedComponents Unchanged
"How would you support scheduling a notification for a future time?"The NotificationRequest (H) gains a scheduled_for field; ingest holds it in a time-indexed store instead of enqueueing immediately, and a lightweight scheduler enqueues it once due — conceptually the same triggering mechanism as Job Scheduler's due-job check (P10-M), reused rather than reinventedIngest logic, a new scheduling componentQueueing, provider adapters, dedup, rate limiting
"We need true read/open receipts, not just 'handed to the provider.'"Extends DeliveryAttempt (H) with a receipt-callback path — most provider ecosystems (push, email) expose delivery/open webhooks that feed back into the same status recordDelivery-status data model, provider adapter callback handlingQueueing, dedup, rate limiting, channel selection
"What if an entire region goes down?"Requires multi-region queue and worker deployment, with dedup/rate-limit state either replicated or intentionally partitioned per-region by user home region, since a cross-region atomic check adds latency the dedup path (P) can't easily absorbDeployment topology, dedup/rate-limit store placementThe dedup and rate-limiting mechanisms themselves
"A calling service wants to know exactly why a notification failed, in real time."Adds a webhook/callback subscription model on top of the existing GET /status polling endpoint (I) — an additive API surface, not a redesignAPI layer onlyEverything structural underneath it

AB. Interviewer Evaluation Rubric

AC. Interview Simulation

Interviewer: Design a notification system that can send push, SMS, email, and in-app notifications, triggered by lots of different internal services.
Candidate: The first thing I want to avoid is calling the actual push/SMS/email provider synchronously inside the request path — that couples whatever service is sending the notification to a third party's latency, and a burst of triggering events has nowhere to absorb the spike. So the core shape is: an ingest API that validates the request and puts it on a queue, then returns immediately, and separate worker pools per channel that pull from that queue at whatever rate they can sustain.
Interviewer: What happens if the calling service's request times out and it retries?
Candidate: That's exactly why every request needs a client-supplied idempotency key. At ingest, I do an atomic check-and-set against Redis — if the key hasn't been seen, it's claimed and the request gets queued; if it has, I just return the original result without queueing anything new. But that's not the whole story — the queue itself only guarantees at-least-once delivery to a worker, so a worker could still get the same message redelivered after a crash. So there's a second, narrower idempotency check right before the worker actually calls the provider, keyed by notification and channel, protecting against that second source of duplication.
Interviewer: How do you keep one really active user from getting spammed with notifications?
Candidate: That's actually a different problem from the duplicate-send one — this is about volume, not duplication. I'd put a per-user, per-channel rate limit in front of delivery, the same token-bucket mechanism as a standard rate limiter, just keyed by user instead of API client. But instead of just dropping whatever's over the limit, I'd batch the overflow into a digest — accumulate it for a short window and send one consolidated notification instead of five separate ones. The one thing I'd explicitly carve out is transactional notifications, like an OTP — those skip rate limiting and digesting entirely, because delaying or batching a security code is a functional bug, not a UX tradeoff.
Interviewer: What if the push provider itself is having an outage?
Candidate: I'd put an adapter layer between the worker pool and the actual vendor, so the worker only ever calls one internal "send" interface, and the adapter behind it can be configured with a ranked list of providers — a primary and a fallback. If the primary starts erroring or timing out past a threshold, new sends shift to the fallback automatically. That way one vendor's bad day degrades that channel instead of taking it down entirely.

AD. Online Solution Comparison

SourceApproachStrengthWeaknessWorth Learning
AWS SNS (official docs)A publish/subscribe topic model where one published message fans out to many independent subscribed endpoints — SQS queues, Lambda functions, HTTP endpoints, email, and, via registered "platform applications," mobile push through APNs/FCM/ADM directly; the documented fan-out pattern is exactly "one event, many independent downstream consumers"The clearest production precedent for M's core fan-out shape — a single triggering event legitimately needing to reach multiple, entirely independent consumers, each at its own paceSNS itself doesn't natively solve per-user deduplication, digesting, or preference management — those are left to whatever's built on top of it, exactly the gap this problem's design fills inConfirms that "one publish, many independent subscribers" is a real, load-bearing production pattern, not an interview simplification
Firebase Cloud Messaging (official docs)Push delivery to registered per-device tokens, plus topic messaging that lets a single send reach every device subscribed to a topic without the caller needing to track individual tokens; delivery is explicitly documented as best-effort, and stale/invalid tokens are surfaced back to the caller for cleanupConfirms N's token-invalidation-and-cleanup reasoning is exactly how a real push provider actually behaves, not a hypothetical edge caseFCM is push-only — it says nothing about SMS, email, or cross-channel preference resolution, all of which this problem's design has to add on topThe explicit "delivery is best-effort, not guaranteed" framing is a direct, real-world confirmation of why this design treats push as one adapter among several, not the whole system
Novu (open-source notification infrastructure, docs)A workflow-based notification layer sitting above individual channel providers — supporting per-subscriber channel preferences, multiple provider integrations per channel with configured ordering, and a "digest" workflow step that explicitly accumulates multiple triggering events over a time window into one consolidated notificationThe most direct real-world precedent for this problem's central integrated decision — preferences, provider abstraction, and digesting, all as first-class, named concerns of the same system rather than afterthoughtsAs an orchestration layer, it still depends on the underlying channel providers (an APNs/FCM, an SMS gateway) for actual delivery — it doesn't replace themConfirms that digesting (Q) and per-subscriber, per-channel preferences (O) are treated as core, load-bearing features in a real production notification system, not embellishments invented for this exercise

AE. Interview Cheat Sheet

ProblemNotification System
DifficultyIntermediate → Advanced (familiar async-offload shape, hard fan-out-and-idempotency surface)
Core requirementsMulti-channel delivery (push/SMS/email/in-app), idempotent submission, per-type/per-channel preferences, queryable delivery status
Central decisionAsync fan-out pipeline (ingest → queue → per-channel worker pools) + pluggable provider adapters + client-supplied idempotency key + per-user rate limiting/digesting
Async offloadIngest validates, dedupes, and enqueues, then returns immediately — the send itself never blocks the caller (Patterns Playbook C)
Dedup vs. rate limitingTwo distinct mechanisms: an idempotency key stops the same event from sending twice; per-user rate limiting/digesting stops too much volume regardless of duplication
Provider abstractionOne internal send() interface per channel, with vendor-specific adapters and ranked failover behind it — never a hardcoded single-vendor call
PriorityPhysically separate queues/worker pools per priority tier so best-effort traffic can never delay critical/transactional delivery
ConsistencyEventually consistent everywhere except one narrow strong-consistency island: the atomic idempotency check itself
Common mistakesSynchronous in-request sends; no idempotency key; conflating dedup with rate limiting; one shared queue for all priorities; retrying permanent failures forever
What NOT to over-engineerReal-time chat delivery (already Chat System's job), a full template CMS, and deep engagement analytics — name them as real extensions (AA), not something to design from scratch here