SkillPilotAI

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)

ItemNumberHow it is derived
Users10 croreAssumption: a large Indian consumer app.
Notifications per day~50 croreAvg 5 notifications per user per day (all channels combined).
Average QPS~6,000/sec50 crore / 86,400 sec.
Peak QPS~60,000/sec~10x of the average at campaign / sale time.
Notification log storage per day~250 GB50 crore x ~500 bytes (id, user, channel, status, timestamps).
Storage per year (90 day hot)~22 TB hot250 GB x 90 days hot in Cassandra; the rest is archived in S3.
Preference cache size~20 GB10 crore users x ~200 bytes preference blob in Redis.

C.3 API design

EndpointPurpose
POST /v1/notificationsSend a single notification: user_id, template_id, params, priority, Idempotency-Key
POST /v1/notifications/bulkCreate a bulk job for a segment/campaign (async, returns job_id)
GET /v1/notifications/{id}View the delivery status
GET /v1/users/{id}/inbox?cursorList of in-app notifications (cursor pagination)
PUT /v1/users/{id}/preferencesUpdate channel/category opt-in, opt-out, quiet hours
POST /v1/templatesCreate a new versioned template

C.4 Data model

Table / StoreFieldsWhy
templates (MySQL)template_id, version, channel, locale, subject, body_with_placeholders, categoryFew 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, timezoneRead on every notification, so a Redis cache is a must.
device_tokens (Cassandra)user_id (partition), device_id, platform, push_token, last_seenA 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_atVery high write volume, time-ordered queries per user.
dedup_keys (Redis with TTL)key = hash(user_id + event_id + channel), TTL 24hA fast duplicate check with SET NX.

C.5 High-level architecture

Notification System: architecture and request flow
  1. 1.Callers: Order Service (transactional), Auth Service (OTP, P0), Marketing Tool (campaign / segment) -> Campaign Scheduler (segment -> 10k chunks)
  2. 2.API Gateway + Auth -> Notification API (Idempotency-Key check, priority topic, 202 Accepted)
  3. 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. 4.Processor: Validator + Dedup (SET NX key), Preference Check (DND, opt-out, cap), Template Renderer (version + locale)
  5. 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. 6.Vendors: SES / SMTP, SMS Vendors (primary + backup), FCM / APNs, WebSocket GW (live inbox)
  7. 7.Support: Retry + DLQ Worker (1m -> 5m -> 30m -> DLQ + alert), Status Tracker via delivery webhooks -> Cassandra notification_log (TTL 90d, then S3)
  8. 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.
ComponentWhat it does
Notification APIValidates the request, checks the Idempotency-Key and puts it on a Kafka topic by priority. Returns 202 Accepted immediately.
Preference + Dedup layerChecks user preference and DND in Redis, then dedups with SET NX. Messages for opted-out users are dropped here.
Template RendererPicks the template version + locale and fills in the variables (a Mustache/Handlebars-like engine).
Kafka priority topicsP0 (OTP), P1 (transactional), P2 (marketing) are separate topics so the marketing backlog does not delay OTP.
Channel WorkersA separate consumer group per channel; throttles with a token bucket according to the vendor's rate limit.
Retry + DLQOn transient failures, exponential-backoff retry topics; after max attempts, a Dead Letter Queue.
Status TrackerConsumes vendor webhooks (delivered, bounced, opened) and updates notification_log.

C.6 Main flows

OTP SMS flow:

  1. The Auth Service calls POST /notifications with priority=P0, template=otp_login and an Idempotency-Key.
  2. The Notification API checks the key; if it is new, it publishes to the Kafka P0 topic and returns 202.
  3. The Processor reads the preference from Redis (OTP is mandatory, DND is skipped).
  4. Dedup: SET NX on hash(user+event+channel); if it already exists, drop.
  5. Template render: "Your OTP is 123456".
  6. The SMS Worker takes a vendor slot from the token bucket and calls the primary SMS vendor.
  7. On a vendor error / timeout the circuit breaker switches to the secondary vendor.
  8. The status DELIVERED is updated in Cassandra from the delivery report webhook.

Marketing campaign flow:

  1. The marketing team schedules a campaign: segment = active users in Delhi, time = 6 PM.
  2. The Campaign Scheduler splits the segment into chunks (for example 10,000 users).
  3. Every chunk goes to the P2 Kafka topic.
  4. The Processor checks every user's preference, quiet hours and frequency cap (for example max 3 promos/day).
  5. For allowed users, the push/email workers send at a rate-limited speed.
  6. Opens/clicks are tracked and go into the analytics pipeline.

Retry and fallback flow:

  1. The Push Worker calls FCM and gets an invalid-token or unregistered error.
  2. The invalid token is deleted from the device_tokens table.
  3. If it is transactional and the user has no valid device, an SMS fallback event is published.
  4. On a 5xx / timeout the message goes to a retry topic: 1 min, 5 min, 30 min backoff.
  5. After max attempts: DLQ, an alert and manual review.

C.7 Deep dives

OTP P0 path: end-to-end p99 < 5 sec
  1. 1.1. Auth Service: priority=P0, otp_login + Idempotency-Key
  2. 2.2. Notification API: new key -> P0 topic, 202 Accepted
  3. 3.3. Kafka P0: a separate topic for OTP, away from marketing
  4. 4.4. Preference: OTP is mandatory, DND skipped
  5. 5.5. Dedup + Render: SET NX; "Your OTP is 123456"
  6. 6.6. SMS Worker: vendor slot from the token bucket
  7. 7.7. Circuit breaker: switch on error / timeout; Vendor A primary, Vendor B backup
  8. 8.8. Webhook: DLR DELIVERED -> Cassandra status

P0 workers are dedicated + over-provisioned; weighted consumption keeps P2 from starving too.

Retry + fallback: push fail
  1. 1.Push Worker -> FCM / APNs
  2. 2.Invalid token (unregistered error): delete from device_tokens (Cassandra)
  3. 3.SMS fallback event: transactional + no valid device
  4. 4.5xx / timeout (transient failure): retry topics with backoff 1 min -> 5 min -> 30 min
  5. 5.DLQ: after max attempts, alert + manual review
Preventing duplicates: two layers
  1. 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. 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. 3.First time: OK, send; second time: nil, drop
  4. 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

DecisionWhat was chosen and why
Kafka vs RabbitMQ/SQSChose Kafka: high throughput, replay, multiple consumers. For per-message delay, separate retry topics are needed.
At-least-once vs exactly-onceChose at-least-once + dedup; true exactly-once is not possible with external vendors.
Sync vs async APIAsync (202 Accepted): the caller is freed fast; polling or a callback for status.
Single vendor vs multi-vendorMulti-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.