System Design Interview Roadmap

System Design Interview Roadmap

System Design Walkthrough

Notification Service — Model Answer

Sep 01, 2026
∙ Paid

Week 1 Take-Home: Reference solution released in Week 2

How to use this: Read your own submission first. Score it against the rubric. Then read this. Find the specific gap. That gap is what you drill this week. Don’t read this before submitting your own answer — the cold attempt is the point.

The question (recap)

Design a notification service. Users have devices; events generate notifications; notifications need to be delivered via push, email, SMS, or in-app depending on user preferences. Failures should retry. The system handles 100M users and 10M notifications per hour at peak.

Step 1 — Clarify

Before drawing anything, three questions matter most:

1. What triggers a notification? Internal events (new message, order shipped, payment received) vs external events (third-party webhooks). The answer shapes the ingestion layer — do producers call an API, or does the service subscribe to an internal event bus?

2. What delivery channels? Push (APNs/FCM), email (SendGrid/SES), SMS (Twilio), in-app (WebSocket). Each has different latency tolerance and reliability guarantees. Push notifications: best-effort, can drop. Emails: must deliver, queued. SMS: must deliver, expensive. In-app: only when the user is connected.

3. What are the SLOs per channel? Push notifications should fire within 5 seconds of the triggering event. Emails within 30 seconds. SMS within 60 seconds. In-app real-time (< 1 second). These numbers drive the queue and worker design.

Step 2 — Estimate

- 100M users, 10M notifications/hour peak = ~2,800 notifications/sec

- Channel distribution (typical): 70% push, 20% email, 8% in-app, 2% SMS - Push: 2,000/sec. Email: 560/sec. In-app: 224/sec. SMS: 56/sec

- Notification record size: ~500 bytes (user_id, channel, template, payload, status) - Storage: 10M/hour × 24h × 30d × 500 bytes ≈ 3.6 TB/month of notification history - User preferences: 100M users × 200 bytes per preference record ≈ 20 GB — fits in a single replicated RDBMS

The 70/20/8/2 channel split drives the worker pool sizing. Push workers handle 70% of the load and need the most horizontal scale.

Get Access to GitHub Repo

Step 3 — API Design

POST /v1/notifications/send

Body: {

user_id: string, // or user_ids[] for bulk

template_id: string, // references a pre-defined template

payload: { key: value }, // template variables

priority: “high” | “normal” | “low”,

dedup_key: string, // optional — prevent duplicate notifications

send_at: epoch_ms, // optional — scheduled delivery

}

Response: { notification_id, status: “queued” }

GET /v1/notifications/:notification_id/status

Response: { notification_id, channel, status, delivered_at, attempts }

PUT /v1/users/:user_id/preferences

Body: {

push_enabled: bool,

email_enabled: bool,

sms_enabled: bool,

quiet_hours: { start: “22:00”, end: “08:00”, timezone: “Asia/Kolkata” },

channel_overrides: { “order_shipped”: [”email”], “message”: [”push”, “in_app”] } }

The dedup_key is the idempotency mechanism. If a producer calls /send twice for the same logical event (retry after a timeout), the duplicate is detected by dedup_key and the second call returns the original notification_id without re-queuing. Same pattern as Stripe’s idempotency key.

The template_id separates content from delivery. Producers don’t write notification text — they reference a pre-defined template and pass variables. This allows marketing/product teams to update notification copy without engineering deploys, and enables A/B testing of notification content independently of the delivery system.

Step 4 — Data Model

notifications table

user_preferences table

user_id (PK), push_enabled, email_enabled, sms_enabled,

quiet_hours_start, quiet_hours_end, timezone, channel_overrides (JSONB)

notification_templates table

template_id (PK), name, channel, subject (email), body_template,

variables_schema (JSONB), created_at, updated_at

device_tokens table

user_id (INDEXED), device_id, platform (ios/android/web),

token (FCM or APNs token), last_active_at, is_active

One user has many device tokens. When sending push, the service fetches all active tokens for the user and sends to each. If a token comes back as invalid (APNs/FCM returns “unregistered”), mark is_active = false immediately.

Preparing for a distributed systems interview?
→Download the free Interview Pack
→ Subscribe now to access source code repository - 200 + coding lessons

User's avatar

Continue reading this post for free, courtesy of System Design Roadmap.

Or purchase a paid subscription.
© 2026 SystemDR Inc · Privacy ∙ Terms ∙ Collection notice
Start your SubstackGet the app
Substack is the home for great culture