Chaturmind
LearnDSASystem DesignDevOpsEngineering GrowthBlog
Start learning
Chaturmind

Structured learning paths for engineers who want to go deep. Written by practitioners.

Learn

  • Java
  • DSA
  • System Design
  • Spring Boot
  • AI / ML
  • DevOps
  • Engineering Growth

Company

  • Blog
  • Contact

Legal

  • Privacy Policy
  • Terms of Service

© 2026 Chaturmind. All rights reserved.

Built for engineers who want to go deep.

← System Design Interview Playbook

Interview Framework

  • The 6-Step Design Framework
  • HLD Fundamentals Refresher
  • Requirement Gathering Practice
  • Domain Decomposition
  • API Contract Design
  • Data Ownership Model
  • Failure Scenario Walkthroughs
  • Architecture Diagramming
  • Back-of-Envelope Estimation

10 Case Studies

  • Design a URL Shortener
  • Design Twitter / X
  • Design WhatsApp
  • Design Netflix
  • Design a Rate Limiter
  • Design a Search Autocomplete
  • Design a Distributed Cache
  • Design a Notification Service
  • Design Uber / Ride Sharing
  • Design a Distributed File Storage System
  • Design a Distributed Task Scheduler
  • Design a Message Queue System
  • Design an Authentication System at Scale
  • Design a Distributed Logging & Metrics Pipeline
  • Design a Food Delivery Platform
  • Design a Real-Time Analytics Dashboard
  • Design a Monitoring & Alerting System
  • Design Container Orchestration Basics
  • Design a CI/CD Pipeline System
  • Design Service Mesh Basics
  • Design a Centralized Configuration & Secrets System
  • Design a Batch Processing System
  • Design a Data Warehouse / Analytics Storage Layer
  • Design Global Content Delivery
  • Case studies

    🏗️Design a URL Shortener
  • 🏗️Design a Rate Limiter
  • 🏗️Design Twitter / X
  • 🏗️Design WhatsApp
  • 🏗️Design Netflix
  • 🏗️Design a Distributed Cache
  • 🏗️Design a Notification Service
  • 🏗️Design a Search Autocomplete System
  • 🏗️Design Uber / Ride Sharing
  • 🏗️Design a Web Crawler
  • 🏗️Design a Payment System
  • 🏗️Design a Distributed Lock Service
  • 🏗️Design a Video Streaming Platform
  • 🏗️Design a Search Engine
  • 🏗️Design E-Commerce Checkout & Inventory at Scale
Chaturmind
← System Design Interview Playbook

Interview Framework

  • The 6-Step Design Framework
  • HLD Fundamentals Refresher
  • Requirement Gathering Practice
  • Domain Decomposition
  • API Contract Design
  • Data Ownership Model
  • Failure Scenario Walkthroughs
  • Architecture Diagramming
  • Back-of-Envelope Estimation

10 Case Studies

  • Design a URL Shortener
  • Design Twitter / X
  • Design WhatsApp
  • Design Netflix
  • Design a Rate Limiter
  • Design a Search Autocomplete
  • Design a Distributed Cache
  • Design a Notification Service
  • Design Uber / Ride Sharing
  • Design a Distributed File Storage System
  • Design a Distributed Task Scheduler
  • Design a Message Queue System
  • Design an Authentication System at Scale
  • Design a Distributed Logging & Metrics Pipeline
  • Design a Food Delivery Platform
  • Design a Real-Time Analytics Dashboard
  • Design a Monitoring & Alerting System
  • Design Container Orchestration Basics
  • Design a CI/CD Pipeline System
  • Design Service Mesh Basics
  • Design a Centralized Configuration & Secrets System
  • Design a Batch Processing System
  • Design a Data Warehouse / Analytics Storage Layer
  • Design Global Content Delivery
  • Case studies

    🏗️Design a URL Shortener
  • 🏗️Design a Rate Limiter
  • 🏗️Design Twitter / X
  • 🏗️Design WhatsApp
  • 🏗️Design Netflix
  • 🏗️Design a Distributed Cache
  • 🏗️Design a Notification Service
  • 🏗️Design a Search Autocomplete System
  • 🏗️Design Uber / Ride Sharing
  • 🏗️Design a Web Crawler
  • 🏗️Design a Payment System
  • 🏗️Design a Distributed Lock Service
  • 🏗️Design a Video Streaming Platform
  • 🏗️Design a Search Engine
  • 🏗️Design E-Commerce Checkout & Inventory at Scale
HomeLearnSystem Design Interview Playbook10 Case Studies
Notification Service

Design a Notification Service

message-queuepush-notificationsemailfan-out

Problem Statement

Design a notification service that can deliver push notifications, SMS, and emails at scale. Notifications may be triggered by system events (payment confirmed) or marketing campaigns (10M users simultaneously). Delivery must be tracked.

Requirements

Functional

  • ✓Send push notifications (iOS APNs, Android FCM)
  • ✓Send SMS (Twilio)
  • ✓Send email (SendGrid)
  • ✓User notification preferences (opt-out, channel preferences)
  • ✓Delivery tracking (sent, delivered, failed, clicked)
  • ✓Template management and personalisation
  • ✓Batch/campaign notifications to millions of users

Non-Functional

  • ✓100M notifications/day
  • ✓Push delivery latency < 10 seconds from trigger
  • ✓At-least-once delivery with idempotency (no duplicates shown to user)
  • ✓Graceful degradation if a channel (e.g., APNs) is down

Capacity Estimation

Capacity Estimation

  • Rate: 100M notifications/day = 1,157 notifications/sec average
  • Peak (marketing blast to 10M users): 10M / 60 seconds = 166K/sec — must queue and fan out
  • Delivery records: 100M × 365 × 3 bytes status = ~110 GB/year
  • Templates: small, fit in cache

High-Level Architecture

Architecture

[Event Sources]
  Payment Service → payment.confirmed event
  Marketing Tool → campaign.triggered event
           │
           ▼
    [Notification API Service]
      - validates request
      - checks user preferences
      - resolves template
      - publishes to channel-specific Kafka topic

           ↓
  ┌─────────────────────────────────────┐
  │         Kafka Topics                │
  │  notifications.push                 │
  │  notifications.email                │
  │  notifications.sms                  │
  └─────────────────────────────────────┘
           ↓
  [Channel Workers] (scale independently)
    Push Worker → APNs / FCM
    Email Worker → SendGrid
    SMS Worker → Twilio
           ↓
  [Delivery Tracker] → updates delivery_status in DB

Implementation focus: NotificationDispatcher class hierarchy

interface NotificationChannel { void send(Notification notification); }

class EmailChannel implements NotificationChannel {
    public void send(Notification n) { /* SMTP send */ }
}
class SmsChannel implements NotificationChannel {
    public void send(Notification n) { /* SMS gateway send */ }
}
class PushChannel implements NotificationChannel {
    public void send(Notification n) { /* APNs/FCM send */ }
}

class NotificationDispatcher {
    private final Map<ChannelType, NotificationChannel> channels;
    NotificationDispatcher(Map<ChannelType, NotificationChannel> channels) { this.channels = channels; }

    void dispatch(Notification notification) {
        NotificationChannel channel = channels.get(notification.getChannelType());
        channel.send(notification); // Strategy pattern — channel selected at dispatch time, no branching per channel
    }
}

This is Strategy Pattern applied per channel (see Strategy Pattern): NotificationDispatcher never branches on channel type internally — it looks up the right NotificationChannel implementation and delegates. Adding a new channel (e.g. Slack) means writing one new class and registering it in the map, with zero changes to NotificationDispatcher itself — the same OCP argument made throughout the pattern lessons.

Retry queue with exponential backoff, as a class

class RetryableNotificationQueue {
    private final DelayQueue<DelayedNotification> queue = new DelayQueue<>();
    private static final int MAX_RETRIES = 5;

    void enqueue(Notification notification, int attempt) {
        long delayMs = (long) (1000 * Math.pow(2, attempt)); // exponential: 1s, 2s, 4s, 8s, 16s...
        queue.put(new DelayedNotification(notification, attempt, delayMs));
    }

    void processNext() throws InterruptedException {
        DelayedNotification item = queue.take(); // blocks until an item's delay has elapsed — see BlockingQueue family
        try {
            dispatcher.dispatch(item.notification);
        } catch (DeliveryException e) {
            if (item.attempt < MAX_RETRIES) enqueue(item.notification, item.attempt + 1);
            else deadLetterQueue.add(item.notification); // give up — route to a dead-letter queue for manual inspection
        }
    }
}

DelayQueue (a BlockingQueue variant not covered in Concurrent Utilities & Coordination's family list, specifically designed for "not ready until time T" semantics) is the natural fit here — it only releases an item via take() once its delay has elapsed, which is exactly the shape exponential backoff needs, without any manual timer/scheduling code.

Template design for notification content across channels

abstract class NotificationTemplate {
    final String render(NotificationContext ctx) { // Template Method — fixed sequence, customizable steps
        String subject = renderSubject(ctx);
        String body = renderBody(ctx);
        return formatForChannel(subject, body);
    }
    protected abstract String renderSubject(NotificationContext ctx);
    protected abstract String renderBody(NotificationContext ctx);
    protected abstract String formatForChannel(String subject, String body);
}

class EmailTemplate extends NotificationTemplate {
    protected String renderSubject(NotificationContext ctx) { return "Your order " + ctx.orderId() + " shipped"; }
    protected String renderBody(NotificationContext ctx) { return "<html>...</html>"; }
    protected String formatForChannel(String subject, String body) { return subject + "\n\n" + body; }
}

class SmsTemplate extends NotificationTemplate {
    protected String renderSubject(NotificationContext ctx) { return null; } // SMS has no subject
    protected String renderBody(NotificationContext ctx) { return "Order " + ctx.orderId() + " shipped!"; } // must be terse
    protected String formatForChannel(String subject, String body) { return body; } // subject ignored entirely
}

Template Method (see Template Method) fits precisely because content generation for every channel follows the same fixed sequence (build subject, build body, format for the channel) while each channel customizes individual steps — an SMS template overriding renderSubject to return null and formatForChannel to ignore it entirely is a clean way to express "this channel doesn't have this concept" without special-casing it in the shared render() method.

Priority handling: keeping a HIGH-priority notification from queuing behind a 10M-user campaign

notifications.push.high     -- payment confirmations, security alerts: separate topic,
                                its own dedicated worker pool, never contends with campaign traffic
notifications.push.default  -- normal transactional notifications
notifications.push.bulk     -- marketing campaigns: separate topic, own worker pool,
                                explicitly throttled to protect provider rate limits

A single shared queue for all push notifications means a 10M-user marketing campaign enqueued just before a time-critical payment-confirmation notification would force that payment notification to wait behind millions of lower-priority messages — directly violating the push delivery latency requirement for the notification that matters most. The fix is priority-segregated topics/queues with SEPARATE worker pools per priority tier, not a single shared queue with an in-memory priority sort (which doesn't actually solve the problem once a worker has already pulled a large batch of low-priority messages) — this is the same principle as Alerting Strategy's symptom-based-alert urgency applied to notification delivery infrastructure.

Notification batching / digest to avoid spam

Sending every individual low-priority event (e.g. "someone liked your post") as its own immediate push notification risks notification fatigue — a direct analog of Alerting Strategy's alert-fatigue problem, applied to end users instead of on-call engineers. A digest/batching layer accumulates low-priority notifications over a short window (e.g. 15-30 minutes) and sends a single combined notification ("5 people liked your post") rather than 5 separate pushes — this requires the notification service to distinguish notification categories upfront (which are safe to batch vs which must be delivered immediately, like a security alert or a payment confirmation) and apply different delivery timing per category, not a single uniform delivery policy for every notification type.

Unsubscribe and compliance handling

Every marketing/campaign notification (email specifically, per CAN-SPAM and similar regulations) must include a functioning unsubscribe mechanism, and an unsubscribe request must be honored immediately and durably — checked against notification_prefs (already in the schema) BEFORE a campaign send, not after. A campaign that ignores a very recent opt-out (due to using a stale, pre-fetched recipient list — see Scaling for Campaign Blasts' 'pre-compute recipient lists' step) is a real compliance risk; the recipient-list pre-computation step needs to re-check preferences as close to send-time as is practical, not rely purely on a list resolved hours earlier.

API Design

API Design

// Trigger single notification
POST /api/v1/notifications
Body:
{
  "userId": "user-123",
  "template": "payment_confirmed",
  "data": { "amount": "$49.99", "orderId": "ORD-789" },
  "channels": ["PUSH", "EMAIL"],
  "priority": "HIGH"
}

// Trigger campaign (batch)
POST /api/v1/campaigns
Body:
{
  "segmentId": "premium_users",
  "template": "new_feature_announcement",
  "scheduledAt": "2025-09-20T09:00:00Z"
}

// Check delivery status
GET /api/v1/notifications/{notificationId}/status

Database Design

Database Design

Notifications (Cassandra — write-heavy delivery logs)

notification_log
  notification_id  UUID    PK
  user_id          UUID
  channel          ENUM    (PUSH, EMAIL, SMS)
  template_id      VARCHAR
  status           ENUM    (PENDING, SENT, DELIVERED, FAILED, CLICKED)
  created_at       TIMESTAMP
  delivered_at     TIMESTAMP

User preferences (PostgreSQL)

notification_prefs
  user_id          UUID    PK
  channel          VARCHAR
  enabled          BOOLEAN
  updated_at       TIMESTAMP

Templates (Redis cache backed by DB)

template:{templateId}  →  { subject, body_html, body_text }

Scaling Strategy

Scaling for Campaign Blasts

Sending to 10M users in <60 seconds requires:

  1. Pre-compute recipient lists (async, before scheduled time)
  2. Parallel Kafka partitions: 100 partitions × 100K messages/partition = 10M messages fanned out
  3. Worker fleet: 100 push workers × 1,600 notifications/sec each = 160K push/sec
  4. APNs/FCM limits: Both support batch send APIs (~1000 tokens per batch call)
Campaign trigger → segment resolver → writes 10M user IDs to Kafka
                                              │
                               100 Kafka partitions
                                              │
                               100 Push Worker instances
                                              │
                               10M APNs/FCM calls over 60 seconds

Multi-region delivery and provider failover

At genuine global scale, the notification pipeline itself should run active-active across multiple regions (Design Global Content Delivery) — a user's notification is processed and sent from whichever region is nearest their associated provider endpoint, reducing delivery latency and avoiding a single region's outage blocking notification delivery entirely. Each channel worker should also support PROVIDER FAILOVER as a first-class concern, not an afterthought: if the primary SMS provider (Twilio) is degraded or down, the SMS worker fails over to a SECONDARY provider (Vonage) automatically — directly extending the Failure Scenarios section's SMS-provider-failure discussion from a manual mitigation into an automated, designed-for capability.

Deduplication at scale: when multiple upstream producers might retry the same event

Event producer (e.g. Order Service) publishes 'OrderShipped' -> a network blip means
the producer isn't sure its publish succeeded -> producer RETRIES the same event
  -> Notification Service must not send the SAME notification twice to the user

This is the same idempotent-consumer problem from Message Queue System and Payment — Idempotency Implementation, applied specifically to notification delivery: an upstream producer's own retry (after an ambiguous publish outcome) can result in the Notification Service receiving the logically-same event more than once. A DEDUPLICATION KEY (derived from the event's own natural identity — e.g. orderId + eventType, not a fresh UUID per publish attempt) checked against a short-lived dedup cache (Redis, with a TTL matching the realistic retry window) before actually dispatching is what prevents the user from seeing the same 'your order shipped' notification twice, without requiring every upstream producer to independently solve this problem.

Delivery analytics

Beyond the existing per-notification delivery_status tracking (Database Design), delivery ANALYTICS aggregates this into actionable signals: delivery success rate per channel (a dropping push-notification success rate might indicate expiring/invalid tokens accumulating, per the Bottlenecks section), delivery latency percentiles per channel (Metrics & Monitoring's percentile-over-average reasoning, applied here), and click-through rate per template (feeding back into which notification content/templates are actually effective). This is naturally implemented as its own downstream consumer of the same notification_log stream (Event-Driven Architecture Patterns' event-carried-state-transfer pattern) — a dedicated analytics pipeline reading delivery events, rather than burdening the hot dispatch path with analytics computation directly.

Priority bypass: why a security alert can't sit behind a marketing blast

This is the exact scenario the priority-segregated topics (added earlier in this case's Architecture section) exist to solve concretely: a critical security alert and a 10M-user marketing campaign must never share the same queue/worker pool, specifically because a campaign enqueued moments before a critical alert would otherwise force that alert to wait behind millions of lower-priority messages — the separate notifications.push.high topic with its own dedicated, never-shared worker pool is what guarantees a security alert's delivery latency stays bounded regardless of what else is happening on the bulk/marketing path at that exact moment.

Trade-offs

  • At-least-once vs exactly-once: Kafka guarantees at-least-once. De-duplicate at the client (track notification_id) to avoid showing the same notification twice.
  • Fire-and-forget vs delivery tracking: tracking requires storing delivery records (~100M rows/day) — adds cost and latency. Make it opt-in per notification type.
  • Push vs in-app notification: push reaches offline users but can't be revoked; in-app is revocable but requires an open session.

Bottlenecks

  • APNs/FCM rate limits: enforce per-app-per-token limits by distributing across multiple APNs connections
  • Invalid tokens: ~5% of push tokens are stale. Purge invalid tokens from DB based on APNs/FCM feedback service
  • Email reputation: high bounce/spam rates lower sender score. Use dedicated IPs and honour unsubscribes immediately

Failure Scenarios

  • APNs down: messages buffered in Kafka. Workers retry with exponential backoff. Campaign delayed, not lost.
  • SMS provider failure: fallback to secondary SMS provider (Vonage → Twilio).
  • Worker crash: Kafka consumer group rebalances — unprocessed messages reassigned to healthy workers.

Previous · Case study

Design a Distributed Cache

Next · Case study

Design a Search Autocomplete System