Case Study 8: Notification System (Email, SMS, Push, In-App)
A central platform that sends messages on behalf of all services to the right user, on the right channel, at the right time, without duplicates.
C.1 Requirements
Functional requirements:
- Internal services send notifications through one API (to a single user, in bulk, or to a segment).
- Channels: Email, SMS, Mobile Push (FCM/APNs), In-App inbox.
- Templates with variables and multi-language support (Hindi, English, etc.).
- User preferences: respect which channel, which category and quiet hours (DND).
- Priorities: OTP/transactional high priority, marketing low priority.
- Retry with backoff on failure, and a fallback channel (if push fails, SMS).
- Deduplication: the user must not get two messages for the same event.
- Delivery status tracking: sent, delivered, opened, failed.
Non-functional requirements:
- OTP/transactional notification end-to-end < 5 sec (p99).
- High throughput for marketing campaigns: 1 crore (10 million) messages in an hour.
- At-least-once delivery + dedup = an effectively exactly-once user experience.
- 99.99% availability of the notification ingestion API.
- Vendor rate limits (SMS provider TPS, FCM quota) must never be breached.
- Horizontal scale: every channel worker scales independently.
C.2 Estimation (back-of-the-envelope)
| Item | Number | How it is derived |
|---|---|---|
| Users | 10 crore | Assumption: a large Indian consumer app. |
| Notifications per day | ~50 crore | Avg 5 notifications per user per day (all channels combined). |
| Average QPS | ~6,000/sec | 50 crore / 86,400 sec. |
| Peak QPS | ~60,000/sec | ~10x of the average at campaign / sale time. |
| Notification log storage per day | ~250 GB | 50 crore x ~500 bytes (id, user, channel, status, timestamps). |
| Storage per year (90 day hot) | ~22 TB hot | 250 GB x 90 days hot in Cassandra; the rest is archived in S3. |
| Preference cache size | ~20 GB | 10 crore users x ~200 bytes preference blob in Redis. |
C.3 API design
| Endpoint | Purpose |
|---|---|
| POST /v1/notifications | Send a single notification: user_id, template_id, params, priority, Idempotency-Key |
| POST /v1/notifications/bulk | Create a bulk job for a segment/campaign (async, returns job_id) |
| GET /v1/notifications/{id} | View the delivery status |
| GET /v1/users/{id}/inbox?cursor | List of in-app notifications (cursor pagination) |
| PUT /v1/users/{id}/preferences | Update channel/category opt-in, opt-out, quiet hours |
| POST /v1/templates | Create a new versioned template |
C.4 Data model
| Table / Store | Fields | Why |
|---|---|---|
| templates (MySQL) | template_id, version, channel, locale, subject, body_with_placeholders, category | Few writes; relational is fine for versioning and the admin UI. |
| user_preferences (MySQL + Redis cache) | user_id PK, channel_optins JSON, category_optouts, quiet_hours, timezone | Read on every notification, so a Redis cache is a must. |
| device_tokens (Cassandra) | user_id (partition), device_id, platform, push_token, last_seen | A user has multiple devices, with high writes on app open. |
| notification_log (Cassandra, partition by user_id) | notification_id, user_id, channel, template_id, status, attempts, created_at | Very high write volume, time-ordered queries per user. |
| dedup_keys (Redis with TTL) | key = hash(user_id + event_id + channel), TTL 24h | A fast duplicate check with SET NX. |
C.5 High-level architecture
- 1.Callers: Order Service (transactional), Auth Service (OTP, P0), Marketing Tool (campaign / segment) -> Campaign Scheduler (segment -> 10k chunks)
- 2.API Gateway + Auth -> Notification API (Idempotency-Key check, priority topic, 202 Accepted)
- 3.Kafka with 3 priority topics: P0 OTP (dedicated workers), P1 transactional (order, payment), P2 marketing (1 crore / hour); weighted consumption P0 > P1 > P2
- 4.Processor: Validator + Dedup (SET NX key), Preference Check (DND, opt-out, cap), Template Renderer (version + locale)
- 5.Channel workers: Email Worker (SES quota), SMS Worker (token bucket), Push Worker (FCM quota), In-App Worker (inbox write); every channel scales separately
- 6.Vendors: SES / SMTP, SMS Vendors (primary + backup), FCM / APNs, WebSocket GW (live inbox)
- 7.Support: Retry + DLQ Worker (1m -> 5m -> 30m -> DLQ + alert), Status Tracker via delivery webhooks -> Cassandra notification_log (TTL 90d, then S3)
- 8.Data: MySQL (templates versioned, user_preferences), Redis (preference cache ~20 GB, dedup keys TTL 24h, vendor token bucket)
Scale: 10 crore users, 50 crore notifications/day, avg 6k QPS, peak 60k, OTP p99 < 5 sec. The OTP path runs through all of these steps from the Auth Service to the status update.
- Order, Auth OTP and the Marketing Tool call the Notification API through the API Gateway; the API checks the Idempotency-Key and returns 202 Accepted immediately.
- Separate Kafka topics by priority: P0 OTP, P1 transactional, P2 marketing (Scheduler 10k chunks); the marketing backlog does not delay OTP.
- The Processor reads preference / DND from Redis, dedups with SET NX and renders the message from the versioned MySQL template.
- Channel workers send within the vendor quota using a token bucket; on a failure / 5xx the Retry + DLQ worker takes over.
- Vendor webhooks -> Status Tracker -> Cassandra notification_log, hot for 90 days, then S3.
| Component | What it does |
|---|---|
| Notification API | Validates the request, checks the Idempotency-Key and puts it on a Kafka topic by priority. Returns 202 Accepted immediately. |
| Preference + Dedup layer | Checks user preference and DND in Redis, then dedups with SET NX. Messages for opted-out users are dropped here. |
| Template Renderer | Picks the template version + locale and fills in the variables (a Mustache/Handlebars-like engine). |
| Kafka priority topics | P0 (OTP), P1 (transactional), P2 (marketing) are separate topics so the marketing backlog does not delay OTP. |
| Channel Workers | A separate consumer group per channel; throttles with a token bucket according to the vendor's rate limit. |
| Retry + DLQ | On transient failures, exponential-backoff retry topics; after max attempts, a Dead Letter Queue. |
| Status Tracker | Consumes vendor webhooks (delivered, bounced, opened) and updates notification_log. |
C.6 Main flows
OTP SMS flow:
- The Auth Service calls POST /notifications with priority=P0, template=otp_login and an Idempotency-Key.
- The Notification API checks the key; if it is new, it publishes to the Kafka P0 topic and returns 202.
- The Processor reads the preference from Redis (OTP is mandatory, DND is skipped).
- Dedup: SET NX on hash(user+event+channel); if it already exists, drop.
- Template render: "Your OTP is 123456".
- The SMS Worker takes a vendor slot from the token bucket and calls the primary SMS vendor.
- On a vendor error / timeout the circuit breaker switches to the secondary vendor.
- The status DELIVERED is updated in Cassandra from the delivery report webhook.
Marketing campaign flow:
- The marketing team schedules a campaign: segment = active users in Delhi, time = 6 PM.
- The Campaign Scheduler splits the segment into chunks (for example 10,000 users).
- Every chunk goes to the P2 Kafka topic.
- The Processor checks every user's preference, quiet hours and frequency cap (for example max 3 promos/day).
- For allowed users, the push/email workers send at a rate-limited speed.
- Opens/clicks are tracked and go into the analytics pipeline.
Retry and fallback flow:
- The Push Worker calls FCM and gets an invalid-token or unregistered error.
- The invalid token is deleted from the device_tokens table.
- If it is transactional and the user has no valid device, an SMS fallback event is published.
- On a 5xx / timeout the message goes to a retry topic: 1 min, 5 min, 30 min backoff.
- After max attempts: DLQ, an alert and manual review.
C.7 Deep dives
- 1.1. Auth Service: priority=P0, otp_login + Idempotency-Key
- 2.2. Notification API: new key -> P0 topic, 202 Accepted
- 3.3. Kafka P0: a separate topic for OTP, away from marketing
- 4.4. Preference: OTP is mandatory, DND skipped
- 5.5. Dedup + Render: SET NX; "Your OTP is 123456"
- 6.6. SMS Worker: vendor slot from the token bucket
- 7.7. Circuit breaker: switch on error / timeout; Vendor A primary, Vendor B backup
- 8.8. Webhook: DLR DELIVERED -> Cassandra status
P0 workers are dedicated + over-provisioned; weighted consumption keeps P2 from starving too.
- 1.Push Worker -> FCM / APNs
- 2.Invalid token (unregistered error): delete from device_tokens (Cassandra)
- 3.SMS fallback event: transactional + no valid device
- 4.5xx / timeout (transient failure): retry topics with backoff 1 min -> 5 min -> 30 min
- 5.DLQ: after max attempts, alert + manual review
- 1.Layer 1, producer-side API: the caller retries with the same Idem-Key -> the Notification API returns the old response, no new Kafka publish
- 2.Layer 2, consumer side (Kafka at-least-once): Kafka redelivery brings the same msg twice -> Redis SET NX on hash(user+event+channel) TTL 24h
- 3.First time: OK, send; second time: nil, drop
- 4.Quiet hours / DND: a P2 marketing msg is parked in a delayed queue and released in the user's timezone; OTP P0 skips DND; frequency cap max 3 promos / day
Problem: a marketing blast delays OTP. Separate Kafka topics + separate worker pools per priority. P0 workers are dedicated and over-provisioned. Weighted consumption keeps low priority from starving.
Problem: duplicate notifications (retry or a producer double send). An Idempotency-Key on the producer side, a Redis SET NX dedup key with a TTL on the consumer side. Kafka is at-least-once, so the consumer must be made idempotent.
Problem: vendor rate limits and outages. A distributed token bucket (Redis) for every vendor. Multiple SMS/email vendors with a circuit breaker and weighted routing; if one goes down, traffic moves to the other.
Problem: user preferences and quiet hours on every message. Preferences are cached in Redis and the cache is invalidated on update. During quiet hours low-priority messages are parked in a delayed queue and released according to the user's timezone.
Problem: storing the status of crores of messages. Cassandra with a user_id partition + time clustering, TTL 90 days. Old data is archived in S3, with a data lake for analytics.
C.8 Trade-offs
| Decision | What was chosen and why |
|---|---|
| Kafka vs RabbitMQ/SQS | Chose Kafka: high throughput, replay, multiple consumers. For per-message delay, separate retry topics are needed. |
| At-least-once vs exactly-once | Chose at-least-once + dedup; true exactly-once is not possible with external vendors. |
| Sync vs async API | Async (202 Accepted): the caller is freed fast; polling or a callback for status. |
| Single vendor vs multi-vendor | Multi-vendor: cost handling is a little more complex, but you get both availability and cost optimization. |
C.9 Interview tips
- Mention priority separation (OTP vs marketing) right at the start; interviewers check for exactly this.
- Explain dedup and idempotency at both layers: the API and the consumer.
- Rate limiting is of two kinds: one that protects users from spam and one for vendor quota.
- Draw the flow of retry with exponential backoff + DLQ + fallback channel clearly.
- Mentioning template versioning and i18n earns a bonus point.