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.
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


