Hirestack
Hirestack
Worked Example 15.11 · 11 of 15

Design a notification service

📖 4 min read · 10 sections · May 2026
Asked at: Stripe Uber Slack Twilio

The setup

"Build a notification service for a SaaS platform. Users receive notifications via push (mobile), email, SMS, in-app. Notifications include: real-time (order placed), digest (daily summary), reminders (subscription expiring), marketing (broadcasts). 10M users; 100M notifications/day; latency requirements vary by channel."

Clarifying questions

QuestionStory answer
What channels?Email, SMS, mobile push (FCM/APNs), in-app (WebSocket).
Per-channel latency targets?Push: <5 sec. Email: <30 sec. SMS: <30 sec. In-app: <1 sec.
Multi-language?Yes — template translations per locale.
User preferences?Users can disable channels per notification type.
Quiet hours?Yes — no notifications during user's local night.
Critical vs marketing?Priority levels; high-priority bypass quiet hours and rate limits.

The architecture

Producers (other services) ──API call──► [Notification API]
                                              │
                                              ↓
                                       [Kafka topic: notifications.requested]
                                              │
                                              ↓
                                  [Notification Dispatcher]
                                  - Look up user preferences
                                  - Apply rate limits / quiet hours
                                  - Determine channels for this notification
                                  - Generate per-channel jobs
                                              │
                       ┌──────────────────────┴──────────────────────┐
                       ↓               ↓             ↓               ↓
              [Push topic]    [Email topic]   [SMS topic]    [In-app topic]
                       │               │             │               │
                  [Push Worker]   [Email Worker]  [SMS Worker]   [In-app Worker]
                       │               │             │               │
                       ↓               ↓             ↓               ↓
                    [FCM/APNs]    [SES/SendGrid]   [Twilio/MSG91]  [WS Gateway]

Design Decision 1: producer decoupling

Producers (the order service, billing service, etc.) shouldn't know about delivery channels. They emit logical notification events: "user X had event Y happen." The notification service handles routing.

Why: adding a new channel (WhatsApp business API) shouldn't require modifying 50 producers. The service in the middle absorbs the channel knowledge.

POST /notifications
{
  "user_id": "u123",
  "type": "order.placed",
  "data": {
    "order_id": "o456",
    "amount": 1500
  },
  "priority": "high"
}
// Notification service decides: which channels, what content, what timing.

Design Decision 2: templating

Notifications need text. Should be templated, not hardcoded in producers.

Template "order.placed":
  push.en: "Your order #{order_id} for ₹{amount} has been placed"
  push.es: "Tu pedido #{order_id} por ₹{amount} ha sido realizado"
  email.en: 
    subject: "Order #{order_id} confirmed"
    body: "Hello {first_name}, your order for ₹{amount} ..."
  sms.en: "Order #{order_id} placed. ₹{amount}. View: {tracking_url}"

Templates per (notification_type, channel, locale). Variables come from the event payload. Stored in a template registry; cached aggressively (1-hour TTL, invalidate on edit).

Design Decision 3: user preferences

Users opt out of marketing emails, prefer push to SMS, have specific channels disabled. Lookup per notification.

user_preferences:
  user_id, notification_type, channel, enabled
  e.g., (u123, marketing, email, false)
        (u123, marketing, push, true)
        (u123, order_updates, email, true)
        ...

Dispatcher logic for incoming notification:
  for each channel in [push, email, sms, in_app]:
    if user_preferences[user_id, notification_type, channel] == enabled:
      enqueue to channel-specific topic

Preference lookups should be cached (Redis): user preferences change rarely; reads are frequent.

Design Decision 4: digest notifications

Interviewer: A user wants daily digest of all activity (not individual notifications). Design.
Notifications flow through normal pipeline but with digest-eligible flag. Instead of immediate delivery, they're stored in a per-user "pending digest" bucket. A digest scheduler runs once daily per user (per their preferred time and timezone): collects all pending items, generates a summary email, sends, clears the queue. Immediate notifications (critical, real-time) still go through; only digest-marked types batch.
Implementation: pending digest stored in Redis sorted set per user, scored by timestamp. Daily cron job per timezone bucket: query users in that timezone whose digest hour matches now; for each, fetch their pending items, render template, send.

Cross-examination round 1: rate limiting per user

Interviewer: Bug in upstream service sends 1000 notifications per second to one user. Don't spam them.
Per-user rate limit at the dispatcher. Token bucket per (user_id, channel): default ~10 notifications/hour per channel; configurable per priority. Critical notifications (security alerts) bypass the limit. Notifications above the limit are dropped (low priority) or coalesced into a digest (medium priority). Log all dropped notifications for analysis.

Cross-examination round 2: delivery failures

Interviewer: SMS provider is down for 5 minutes. What happens?
SMS worker retries with exponential backoff. After N failures, message goes to a dead-letter queue (DLQ). Ops team alerted. When provider recovers, drain the DLQ. Customer experience: SMS arrival is delayed by a few minutes; no data loss.
Follow-up: Multi-provider failover?
A: Yes — for high-volume use cases. Primary provider (cheaper) handles 95%; fallback (more expensive) for resilience. Worker tracks per-provider success rate; auto-switches based on health. SMS providers (Twilio, MessageBird, MSG91) have very different reliability profiles; multi-provider is standard at scale.

Takeaway

Notification services are the canonical multi-channel fanout problem with policy-driven routing. The architectural skill is in decoupling: producers don't know about channels; channels don't know about producers; the service in the middle handles policies (preferences, rate limits, quiet hours), retries, and routing. This decoupling is what enables adding new channels (WhatsApp Business, Slack DMs, etc.) without modifying producers.

The deeper pattern: any time you have N producers × M consumers, put a smart router in the middle. The router absorbs the cross-product complexity. Without it, each producer has to know about each consumer (N × M coupling); with it, N + M coupling.