Notification System Interview Questions | JiQuest

add

#

Notification System

System design deep dive · HLD

Design a multi-channel notification system (push / email / SMS): full high-level design.

Requirements, back-of-envelope capacity estimation, a high-level design diagram, a deeper deployment architecture diagram, sequence diagrams for the send path and the retry/backoff path, and an entity-relationship diagram for the data model - with the reasoning an interviewer expects behind every box and arrow.

5B/moNotifications sent
50K/sPeak campaign burst
3 channelsPush, email, SMS
Event sourceorder shipped Notification APIdedup + prefs Template renderfills variables Push (FCM/APNs) Email (SES) SMS (Twilio) Delivery logstatus per channel

1. Clarify requirements before drawing any box

A notification system's defining tension is that it must both never spam a user who opted out and never silently drop a transactional message like a password reset - those two failure modes have opposite costs.

Functional requirements

Templated notificationsInternal services trigger a named template with variables; the system renders the final content per channel.
Multi-channel fanoutOne trigger can fan out to push, email, and SMS based on template config and user reachability.
Preference enforcementPer-user, per-category opt-outs and quiet hours must be honored before send, not after.
Dedup of duplicate triggersThe same logical event fired twice (e.g. a retried upstream call) must not double-notify the user.

Non-functional requirements

Bursty scaleA marketing campaign can enqueue tens of millions of sends within minutes.
Reliable deliveryTransient provider failures are retried with backoff rather than dropped.
Priority separationA transactional OTP must not queue behind a marketing blast.
AuditabilityEvery send attempt, per channel, per recipient, is logged with a final status.
Explicitly out of scope In-app notification center/inbox UI, A/B testing of message copy, and campaign scheduling/segmentation tooling are called out as adjacent products rather than core requirements, so the core design stays focused on reliable, preference-aware, multi-channel delivery.

2. Back-of-the-envelope capacity estimation

These numbers decide whether a single queue is enough, why transactional and bulk traffic must be physically separated, and how large the dedup cache needs to be.

MetricAssumptionResulting estimate
Total notifications5 billion/month across all channels (transactional + marketing)~1,930 sends/sec average
Peak campaign burstA single marketing blast enqueues 50M sends in 20 minutes~42,000 enqueues/sec peak, absorbed by queue depth not synchronous processing
Transactional trafficOTP/receipt/alert sends, ~5% of total, latency-sensitive~250M/month, ~96 sends/sec average, needs its own priority lane
Delivery log storage~300 bytes/attempt (channel, provider, status, timestamps), incl. retries5B/mo × 1.2 (retry factor) × 300B ≈ 1.8 TB/month
Dedup cacheIdempotency key TTL of 24h, ~170M unique triggers/day~170M keys × ~80 bytes ≈ 14 GB - fits a Redis cluster
Provider rate limitsSMS provider caps ~2,000 msgs/sec per accountrequires client-side rate limiting/sharding across provider accounts to avoid 429s during bursts
Why this matters The 500x gap between average throughput and a single campaign's peak enqueue rate is the number that justifies decoupling "accept the request" from "actually call the provider" with a durable queue in between - the API can absorb a burst in milliseconds while workers drain it over minutes.

3. High-level design (HLD)

The HLD names the major components and the one-directional data flow from a trigger to a delivered (or failed) notification, without yet committing to region counts, provider accounts, or queue partitioning.

Event sourcesorder, auth, billing Notification APIingest + dedup Dedup cacheidempotency key Preference svcopt-out / quiet hours Template renderper-channel content Channel queues1 per channel + priority Push worker → FCM/APNs Email worker → SES SMS worker → Twilio Delivery log + retry/backoff queue
Stateless servicesFast-path infraExternal providersAsync / durable

What each box owns

Notification API

The single ingest point for every internal service. It computes an idempotency key (typically a hash of trigger type + user_id + template + a caller-supplied dedup token), checks the dedup cache, and rejects duplicates before any expensive work happens. It never talks to a channel provider directly.

Preference service

Owns per-user opt-outs by category (marketing, product-updates, security-alerts), quiet-hours windows, and channel priority order. Security/transactional categories are marked non-suppressible so a password-reset email can never be silently dropped by a marketing opt-out, which is the specific policy that keeps the two failure modes from the requirements section from colliding.

Template render

Loads the versioned template for the given notification type and renders channel-specific output (push payload, HTML email, SMS text) by substituting variables from the trigger payload. Keeping rendering separate from the API means template edits deploy independently of the ingest path.

Channel queues, workers, and delivery log

Each channel has its own queue so a Twilio outage cannot back up email or push. Workers call the provider, respecting its rate limit, and write a delivery_logs row with the final status. Failures route to a retry/backoff queue rather than being dropped, and repeated failures eventually land in a dead-letter queue for alerting.

4. Detailed architecture diagram

The architecture diagram answers how priority separation, provider rate limits, and retry/backoff are actually deployed - the details an interviewer checks once the HLD shape is accepted.

Ingest layer API gateway + authn Idempotency checkRedis, 24h TTL key Priority classifiertransactional vs bulk Preference + template render Transactional lane (high priority) Dedicated SQS queue Workers ×20, no backpressure Latency SLO: <5s p99OTP, security alerts,payment confirmations Bulk / marketing lane (throughput priority) Sharded queue ×16 partitions Workers auto-scale 5-200 Token-bucket rate limiterper provider account,shards across multiple accounts Providers FCM / APNs SES / Twilio provider abstraction layer, swappable per channel Retry / backoff Backoff queue Dead-letter queue exponential delay, max 5 attempts, then DLQ + alert Delivery log store Append-only, partitioned by day powers delivery-status API + audits
DecisionChoiceReasoning
Priority separationPhysically separate queues for transactional vs bulkA 50M-message marketing blast must never delay a password-reset email; separate queues mean the two workloads can't contend for the same worker pool.
Provider rate limitingToken-bucket limiter sharded across multiple provider accountsProviders hard-cap throughput per account (e.g. ~2,000 SMS/sec); sharding across accounts is the only way to sustain campaign-level burst without 429s.
Retry strategyExponential backoff, capped at 5 attempts, then DLQTransient provider errors (5xx, timeouts) resolve within minutes; permanent errors (invalid number, hard bounce) should fail fast into the DLQ rather than retry forever.
Provider abstraction layerA thin interface per channel, not a direct SDK call from workersLets a channel's provider be swapped (e.g. Twilio to a backup SMS vendor) without touching queueing, dedup, or preference logic.

5. Sequence diagrams for the two critical flows

A sequence diagram is where an interviewer checks whether a candidate actually enforces preferences before rendering content, and whether failure handling is a first-class path rather than an afterthought.

5.1 Trigger a notification and fan out across channels

Order svc Notif. API Preference svc Template render Queues 1. notify(userId, type=order_shipped, idemKey) 2. dedup check: not seen → proceed 3. get preferences(userId, category) 4. allowed channels: push, email 5. render(type, vars) per channel 6. rendered push + email payloads 7. enqueue to push queue + email queue 8. 202 Accepted {notificationId}

Step 2 - the dedup check - happens before any preference lookup or rendering work, specifically so a retried trigger costs almost nothing. Note that SMS is silently excluded in step 4 because the user opted out of that channel for this category; the caller in step 8 gets a 202 immediately and never learns (or needs to know) which channels were actually used - that detail lives in the delivery log.

5.2 Delivery failure, retry with backoff, and dead-lettering

Email queue Email worker SES Backoff queue DLQ 1. dequeue attempt #1 2. send() 3. 500 (transient provider error) 4. log attempt=1, status=failed 5. schedule retry, delay=30s 6. attempts 2-4 repeat with delay 2m,8m,32m; still failing 7. attempt #5 exhausted 8. move to DLQ, page on-call

Every attempt is logged in step 4 regardless of outcome, which is what makes the delivery_logs table a complete audit trail rather than only recording final status. The backoff delays in step 6 double each time specifically to avoid hammering a degraded provider right as it's recovering; after the fifth failure the message is dead-lettered rather than retried indefinitely, converting an invisible silent failure into a paged, actionable alert.

6. Entity-relationship (ER) diagram and schema

The data model has to answer: how is a duplicate trigger detected cheaply, how does a versioned template stay decoupled from a specific send, and how is per-attempt delivery status queried without scanning the whole history.

notifications PK id BIGINT UQ idempotency_key VARCHAR FK template_id BIGINT user_id BIGINT payload_json JSON created_at TIMESTAMP templates PK id BIGINT name,version VARCHAR channels SET body_by_channel JSON delivery_logs PK id BIGINT FK notification_id BIGINT channel ENUM attempt_no INT status,provider VARCHAR sent_at TIMESTAMP user_preferences PK user_id,category BIGINT,VARCHAR channels_opt_in SET quiet_hours VARCHAR N1 1N user_id + category (soft ref) one template is used by many notifications; one notification produces one delivery_logs row per attempt per channel

Key modeling decisions

idempotency_key is a unique constraint on notificationsEnforces dedup at the database layer as a backstop even if the Redis-based cache check races or expires early.
templates are versioned, not overwrittenA notification stores which template version rendered it, so historical delivery_logs remain reproducible even after a template's copy changes.
delivery_logs is one row per attempt, not per notificationRetries need their own row to preserve the full audit trail used by the retry/backoff deep dive; it's append-only and partitioned by day for cheap retention rollover.
user_preferences keyed by (user_id, category)Lets a user opt out of "marketing" while staying opted into "security-alerts" without a separate table per category.
Storage choiceUse whenWatch out for
Relational (Postgres), notifications + templates + preferencesYou need the unique constraint on idempotency_key and simple joins for the delivery-status API.delivery_logs volume (billions of rows/month) will outgrow a single relational table without partitioning/archival.
Wide-column (Cassandra/DynamoDB) for delivery_logs specificallyWrite volume and retention window (90 days) matter more than ad hoc joins for this one table.Querying "all attempts for this notification" needs the partition key chosen up front (notification_id), or it requires a scan.

7. Deep dives interviewers actually probe

How exactly is a duplicate trigger detected?

The idempotency key is a hash of (trigger type, user_id, template_id, and a caller-supplied dedup token - e.g. order_id for a shipping notification). The Notification API checks this key against Redis with a SET NX before doing anything else; if the key already exists, the request is accepted and immediately discarded as a duplicate. The unique constraint on the notifications table is a second, durable line of defense in case two API instances race on the same key within the same millisecond.

Why exponential backoff instead of fixed-interval retry?

A fixed short interval (e.g. retry every 10s) against a provider that's actively degraded just adds more load to a system that's already failing, potentially delaying its recovery. Exponential backoff (30s, 2m, 8m, 32m) spaces retries out specifically so that by the time later attempts happen, a transient provider blip has likely resolved - and it bounds the total retry window so a permanently-broken address doesn't retry for days.

How is a marketing opt-out enforced without ever blocking a security alert?

Every template is tagged with a category, and category is what user_preferences keys on - not the notification as a whole. Security and transactional categories are configured as non-suppressible at the template level, so the preference check for those categories always returns "allowed" regardless of what the user has opted out of, which is a policy decision made explicit in code rather than left to a runtime edge case.

How do you avoid a 50M-message campaign starving normal traffic?

Separate queues per priority lane (section 4) mean the bulk lane's depth is invisible to the transactional lane's workers. Within the bulk lane itself, the queue is sharded into partitions so a single campaign's messages spread across many workers rather than serializing behind one queue's throughput ceiling, and workers auto-scale based on queue depth rather than running a fixed pool sized for average load.

What happens if the chosen SMS provider has an outage?

Because workers call providers through the abstraction layer rather than a hardcoded SDK, a circuit breaker on the primary provider can fail over to a secondary provider account (or vendor) for new sends once error rates cross a threshold, while in-flight retries for the primary continue draining independently - this is why provider identity is a field on delivery_logs rather than an assumption baked into the channel.

8. Summary: what a strong answer covers

Clarified scope before designingJustified every number with a calculationSeparated transactional from bulk traffic Made dedup a first-class, early checkTreated retry/backoff as a designed pathKept security categories non-suppressible
Interview tip When asked to design a notification system, the strongest signal is naming the two competing failure modes up front - never spam an opted-out user, never silently drop a transactional message - and showing exactly which component is responsible for each, rather than treating "send the notification" as a single undifferentiated step.
No comments
Leave a Comment