System Design
    September 9, 202640 min read

    System Design: Building a Notification Service for 50 Million Users

    A complete system design walkthrough — from blank page to production architecture — covering fan-out, multi-channel delivery, rate limiting, retry logic, and the trade-offs engineers get wrong in interviews and production.

    Share

    Every non-trivial application sends notifications. Order confirmations. Password resets. Fraud alerts. Weekly digests. Yet the notification system is one of the most commonly underdesigned pieces of infrastructure — a single service that tries to do everything, with a shared database table that becomes a bottleneck at scale, and no retry logic until the first production outage.

    This post designs a notification service that handles 50 million users, multiple channels (email, SMS, push, in-app), and 500,000 notifications per minute at peak. It's structured the way I'd walk through it in a system design interview — requirements first, capacity estimate, high-level design, component deep dive, trade-off decisions — with architecture diagrams at each stage and Spring Boot code for the non-obvious parts.


    Requirements

    Functional requirements:

    • Send notifications via email, SMS, push notification, and in-app feed
    • User preference management: per-channel, per-notification-type opt-in/opt-out
    • Template management: variable substitution, localization, A/B variants
    • Scheduling: send now, send at a specific time, send in user's local timezone
    • Deduplication: guarantee each notification is delivered at most once
    • Delivery status tracking: sent, delivered, failed, opened

    Non-functional requirements:

    • 50 million users, up to 500,000 notifications/minute at peak (campaign sends)
    • P99 delivery latency < 5 seconds for transactional notifications (password reset, fraud alert)
    • P99 delivery latency < 5 minutes for bulk campaign sends
    • At-least-once delivery (retry on failure), with idempotency to prevent duplicates
    • Multi-region deployment for geo-based regulations (GDPR, SMS routing)

    Out of scope: Real-time bidirectional chat (that's a separate WebSocket service), notification analytics beyond delivery status.


    Capacity Estimate

    Before touching architecture, numbers:

    Metric Value Reasoning
    Daily notifications 200M 4 per user per day average
    Peak notifications/min 500K Campaign sends: 10M users × 3 notifications over 1 hour
    Notification record size 2 KB Template rendered + metadata
    Daily storage (hot) 400 GB 200M × 2 KB
    Storage retention 90 days For audit + re-delivery
    Kafka throughput needed ~8K messages/sec sustained, 8.5K peak 500K/min = 8,333/sec

    This tells us we need a message queue (not direct DB writes) to absorb campaign spikes, and columnar or time-series storage for 90-day retention at 36 TB scale.


    High-Level Architecture

    flowchart TD A[API Clients\nMobile · Web · Backend Services] --> B[Notification API\nSpring Boot · REST] B --> C{Preference\nService} C -->|User opted in| D[Notification Router] C -->|User opted out| Z[/Drop/] D --> E[(Template\nService)] E --> F[Kafka\nnotification-events topic] F --> G[Email Worker\nSES / SendGrid] F --> H[SMS Worker\nTwilio / Vonage] F --> I[Push Worker\nFCM / APNs] F --> J[In-App Worker\nPostgreSQL + WebSocket] G & H & I & J --> K[(Delivery Log\nTimescaleDB)] G & H & I --> L[Dead Letter Queue\nnotification-dlq topic] L --> M[DLQ Processor\nRetry with backoff] M --> F style A fill:#1e293b,stroke:#334155,color:#e2e8f0 style B fill:#0e7490,stroke:#0891b2,color:#f0f9ff style C fill:#7c3aed,stroke:#8b5cf6,color:#faf5ff style D fill:#0e7490,stroke:#0891b2,color:#f0f9ff style E fill:#0e7490,stroke:#0891b2,color:#f0f9ff style F fill:#b45309,stroke:#d97706,color:#fffbeb style G fill:#065f46,stroke:#059669,color:#f0fdf4 style H fill:#065f46,stroke:#059669,color:#f0fdf4 style I fill:#065f46,stroke:#059669,color:#f0fdf4 style J fill:#065f46,stroke:#059669,color:#f0fdf4 style K fill:#1e3a5f,stroke:#2563eb,color:#eff6ff style L fill:#7f1d1d,stroke:#dc2626,color:#fef2f2 style M fill:#7f1d1d,stroke:#dc2626,color:#fef2f2 style Z fill:#374151,stroke:#6b7280,color:#f9fafb

    Seven distinct responsibilities, seven components. Let me walk through each.


    Component Deep Dive

    Notification API

    The entry point. Two types of callers:

    Transactional triggers — backend services (Order Service, Auth Service) calling directly with a structured event:

    POST /api/v1/notifications
    {
      "type": "ORDER_CONFIRMED",
      "recipientId": "usr_abc123",
      "templateVariables": {
        "orderId": "ord_xyz789",
        "totalAmount": "$149.99",
        "estimatedDelivery": "Sep 12"
      },
      "channels": ["EMAIL", "PUSH"],     // override preference? No — preference service decides
      "deduplicationKey": "order-confirm-ord_xyz789"  // idempotency
    }

    Campaign sends — marketing platform sending in bulk. The API accepts a campaign job (not individual notifications) and fans it out internally:

    POST /api/v1/campaigns
    {
      "templateId": "tmpl_monthly_digest",
      "audienceSegmentId": "seg_premium_users",
      "scheduledAt": "2026-09-10T09:00:00",
      "timezoneStrategy": "RECIPIENT_LOCAL"  // send at 9 AM in each user's timezone
    }

    The API layer does nothing except validate, deduplicate, and enqueue. No channel logic, no template rendering, no external calls. It must return in under 50ms.

    Idempotency by deduplicationKey:

    @PostMapping("/notifications")
    public ResponseEntity<NotificationAck> send(@RequestBody @Valid SendRequest request) {
        String dedupKey = request.deduplicationKey();
    
        // Check Redis for recent duplicate (TTL = 24 hours)
        if (deduplicationCache.exists(dedupKey)) {
            return ResponseEntity.ok(NotificationAck.duplicate(dedupKey));
        }
    
        NotificationEvent event = eventFactory.create(request);
        kafkaProducer.send("notification-events", event.recipientId().toString(), event);
        deduplicationCache.set(dedupKey, Duration.ofHours(24));
    
        return ResponseEntity.accepted().body(NotificationAck.accepted(event.id()));
    }

    Using recipientId as the Kafka partition key ensures all notifications for one user land in the same partition — guaranteeing ordering for that user's in-app feed without requiring global ordering.


    Preference Service

    The preference check runs before any message hits the queue. A notification not worth sending is not worth routing, rendering, or failing on.

    flowchart LR A[Incoming Event\ntype · recipientId] --> B{Global\nopt-out?} B -->|Yes| Z[/Drop silently/] B -->|No| C{Channel\npreference?} C -->|EMAIL disabled| D[Remove EMAIL\nfrom channels] C -->|SMS disabled| E[Remove SMS\nfrom channels] C -->|No channels left| Z C -->|Channels remain| F{Quiet hours?} F -->|In quiet hours| G[Schedule for\nend of quiet period] F -->|Not in quiet hours| H[Route to queue] style Z fill:#374151,stroke:#6b7280,color:#f9fafb style G fill:#7c3aed,stroke:#8b5cf6,color:#faf5ff style H fill:#065f46,stroke:#059669,color:#f0fdf4

    Preference data lives in a Redis hash per user — fast enough to check before every notification without being a bottleneck:

    HGETALL user:pref:usr_abc123
    → {
        "global_optout": "false",
        "email_enabled": "true",
        "sms_enabled": "false",
        "push_enabled": "true",
        "quiet_start": "22:00",
        "quiet_end": "08:00",
        "timezone": "America/New_York"
      }

    Preference data is also written to PostgreSQL as the source of truth. Redis is the read cache with a 5-minute TTL. Preference updates invalidate the Redis key immediately.


    Template Service

    Templates are versioned, localized, and support A/B testing:

    tmpl_order_confirmed/
    ├── v3/
    │   ├── en/
    │   │   ├── email.html
    │   │   ├── email.subject
    │   │   ├── sms.txt          (160 char limit enforced)
    │   │   └── push.json        (title + body + data payload)
    │   └── es/
    │       ├── email.html
    │       └── ...
    └── v2/                      ← previous version, still served for in-flight notifications

    Template rendering is synchronous within the worker — not a separate network call — because the worker already has the template variables from the event payload:

    @Service
    class TemplateRenderer {
    
        private final TemplateEngine templateEngine;  // Thymeleaf or Pebble
    
        public RenderedNotification render(NotificationEvent event, Channel channel) {
            Template template = templateRepository.getTemplate(
                event.type(), channel, event.locale(), event.templateVariant()
            );
            return new RenderedNotification(
                templateEngine.process(template.content(), event.variables()),
                channel
            );
        }
    }

    Templates are cached in-process (Caffeine, 10-minute TTL) in each worker. Template cache invalidation sends a Kafka event to all worker instances — they evict and reload on next render.


    Kafka: The Central Backbone

    One topic, multiple consumer groups — one per channel. This is the fan-out mechanism:

    notification-events (topic)
    ├── email-notification-group    → Email Workers (12 instances)
    ├── sms-notification-group      → SMS Workers (8 instances)
    ├── push-notification-group     → Push Workers (10 instances)
    └── inapp-notification-group    → In-App Workers (6 instances)

    Each consumer group processes at its own pace. Email might be slower (due to ESP rate limits) without backing up SMS delivery. The channels are completely decoupled by the queue.

    Partition count is the key scaling decision. At 8,333 messages/second peak and aiming for 1,000 messages/second per partition (leaving headroom), we need at minimum 9 partitions. Use 24 partitions — round number divisible by 1, 2, 3, 4, 6, 8, 12 consumers, giving flexibility to scale workers without rebalancing.


    Channel Workers

    Each worker follows the same structure regardless of channel:

    @KafkaListener(
        topics = "notification-events",
        groupId = "email-notification-group",
        containerFactory = "emailKafkaListenerContainerFactory"
    )
    public class EmailNotificationWorker {
    
        private final TemplateRenderer templateRenderer;
        private final EmailProvider emailProvider;
        private final DeliveryLogService deliveryLog;
    
        @KafkaHandler
        public void handle(NotificationEvent event, Acknowledgment ack) {
            String notificationId = event.id();
            try {
                RenderedNotification rendered = templateRenderer.render(event, Channel.EMAIL);
                EmailResult result = emailProvider.send(rendered, event.recipientEmail());
    
                deliveryLog.record(notificationId, Channel.EMAIL, DeliveryStatus.SENT,
                    Map.of("messageId", result.providerId(), "provider", "SES"));
                ack.acknowledge();
    
            } catch (RetryableException e) {
                // Don't ack — Kafka will redeliver to this consumer group
                log.warn("[{}] Retryable failure on email send, will redeliver: {}", notificationId, e.getMessage());
                // Don't ack — message will be redelivered after max.poll.interval.ms
    
            } catch (PermanentException e) {
                // Ack the message (remove from main topic) and send to DLQ
                deliveryLog.record(notificationId, Channel.EMAIL, DeliveryStatus.FAILED,
                    Map.of("reason", e.getMessage()));
                dlqProducer.send("notification-dlq", event.recipientId(), new DlqEvent(event, e));
                ack.acknowledge();
            }
        }
    }

    Retryable vs. permanent exceptions:

    Exception Class Behavior
    Rate limit (429) Retryable No ack, redeliver after backoff
    Network timeout Retryable No ack, redeliver
    Invalid email address Permanent Ack + DLQ
    Unsubscribed (ESP bounce) Permanent Ack + update preference + skip DLQ
    Template rendering error Permanent Ack + DLQ + alert

    Retry and DLQ Flow

    sequenceDiagram participant W as Channel Worker participant K as Kafka Main Topic participant D as DLQ Topic participant P as DLQ Processor participant L as Delivery Log W->>K: Consume notification event W->>W: Attempt delivery alt Delivery succeeds W->>L: Record SENT status W->>K: Acknowledge (offset committed) else Retryable failure (429, timeout) Note over W,K: Worker does NOT acknowledge K-->>W: Redeliver after poll interval W->>W: Attempt 2 (exponential backoff: 1s, 2s, 4s) W->>W: Attempt 3 alt Succeeds on retry W->>L: Record SENT status W->>K: Acknowledge else Still failing after 3 attempts W->>D: Publish to DLQ with attempt count + error W->>L: Record FAILED status W->>K: Acknowledge (remove from main topic) end else Permanent failure W->>D: Publish to DLQ immediately W->>L: Record PERMANENTLY_FAILED status W->>K: Acknowledge end P->>D: Consume DLQ event P->>P: Check if recoverable (age, error type, attempt count) alt Recoverable and attempt < 5 P->>K: Re-publish to main topic with attempt_count+1 else Max attempts exceeded P->>L: Record ABANDONED status P->>P: Alert on-call (PagerDuty) end

    The DLQ processor runs on a 15-minute schedule for most failures. For transactional notifications (password reset, fraud alert), it runs every 60 seconds — delivery SLA matters more for these.


    Delivery Log (TimescaleDB)

    Delivery tracking generates time-series data. One row per notification per channel, written at every state transition:

    CREATE TABLE notification_delivery_log (
        notification_id  UUID        NOT NULL,
        channel          VARCHAR(20) NOT NULL,
        recipient_id     UUID        NOT NULL,
        status           VARCHAR(30) NOT NULL,   -- SENT, DELIVERED, OPENED, FAILED, ABANDONED
        provider_msg_id  VARCHAR(100),           -- ESP/SMS provider's message ID for callbacks
        error_code       VARCHAR(50),
        attempt_count    INTEGER DEFAULT 1,
        metadata         JSONB,
        occurred_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    
        PRIMARY KEY (notification_id, channel, occurred_at)
    );
    
    SELECT create_hypertable('notification_delivery_log', 'occurred_at');
    
    -- Retention: auto-drop partitions older than 90 days
    SELECT add_retention_policy('notification_delivery_log', INTERVAL '90 days');

    TimescaleDB's hypertable partitioning handles the write volume (10,000+ inserts/second at peak) and makes time-range queries efficient: "show me all failed email notifications in the last 24 hours" hits one or two partitions instead of scanning 36 TB.


    Database Schema

    erDiagram NOTIFICATIONS { uuid id PK varchar type uuid recipient_id FK varchar dedup_key UK jsonb template_variables varchar[] channels varchar status timestamptz scheduled_at timestamptz created_at } USER_PREFERENCES { uuid user_id PK boolean global_optout jsonb channel_preferences varchar quiet_start varchar quiet_end varchar timezone timestamptz updated_at } TEMPLATES { uuid id PK varchar notification_type varchar channel varchar locale integer version text content text subject boolean active timestamptz created_at } CAMPAIGNS { uuid id PK uuid template_id FK varchar segment_id varchar timezone_strategy varchar status timestamptz scheduled_at integer total_recipients integer sent_count timestamptz created_at } NOTIFICATIONS ||--o{ USER_PREFERENCES : "recipient" NOTIFICATIONS ||--o{ TEMPLATES : "uses" CAMPAIGNS ||--|| TEMPLATES : "uses"

    The Trade-offs That Matter

    1. Fan-out on write vs. fan-out on read

    For campaign sends (10 million recipients), you have two strategies:

    • Fan-out on write: When the campaign is submitted, create one notification record per recipient immediately, then process from the queue. Pros: consistent delivery tracking, simple worker logic. Cons: 10M database writes before a single notification is sent.
    • Fan-out on read: Store one campaign record. Workers resolve the recipient list at delivery time. Pros: minimal writes upfront. Cons: recipient list must be re-resolved at send time, harder to track per-recipient status.

    Recommendation: Fan-out on write, but asynchronously. The campaign scheduler writes notification records in batches of 1,000 to Kafka directly, not to PostgreSQL first. Workers consume from Kafka and persist delivery status. The notification record exists in the delivery log, not as a pre-created row in the notifications table.

    2. At-least-once vs. exactly-once delivery

    Exactly-once delivery with Kafka requires Kafka transactions and idempotent producers — significant operational complexity. At-least-once with deduplication in the API layer (the deduplicationKey) gives 99.9% of the benefit with 10% of the complexity.

    The remaining 0.1% case: network partition causes the Kafka producer to retry after a successful write, sending the event twice. The deduplication cache (Redis with 24-hour TTL) catches this if the deduplicationKey is set. Callers that don't set a deduplicationKey accept at-least-once semantics explicitly.

    3. Notification ordering

    In-app notifications need to appear in chronological order for a given user. Email and SMS do not — the user sees them in their email client's sort order anyway.

    The partition-by-recipientId strategy guarantees ordering within a partition for a given consumer group. For in-app, we additionally attach a sequence number from a per-user Redis counter when writing to the in-app feed. This handles the edge case where two events for the same user land in the same partition but the in-app worker processes them out of order (rare, but possible with concurrent workers).

    4. Template rendering location

    Render at the API layer (before enqueue) or at the worker layer (after dequeue)?

    Rendering at the worker layer is better because:

    • Template data can be stale at render time rather than at enqueue time (template variables fetched fresh)
    • Failed renders become visible at delivery time with full context for debugging
    • Template updates between enqueue and delivery pick up the new template (important for campaigns scheduled days ahead)

    The cost: the template variables must be carried in the Kafka event payload, not fetched again at render time. Include all needed data in the event at enqueue time.


    Scaling to 500,000 Notifications Per Minute

    flowchart LR subgraph "API Tier (stateless, auto-scale)" A1[API Instance 1] A2[API Instance 2] A3[API Instance N] end subgraph "Kafka (24 partitions)" K[notification-events] end subgraph "Email Workers (12 instances = 12 partitions)" E1[Email Worker 1] E2[Email Worker 2] E3[Email Worker 3..12] end subgraph "SMS Workers (8 instances)" S1[SMS Worker 1..8] end subgraph "Push Workers (10 instances)" P1[Push Worker 1..10] end subgraph "In-App Workers (6 instances)" I1[InApp Worker 1..6] end A1 & A2 & A3 --> K K --> E1 & E2 & E3 K --> S1 K --> P1 K --> I1

    At 500,000/min (8,333/sec), with 24 partitions, each partition handles ~347 events/sec. Email workers (slowest channel due to ESP rate limits) use 12 partitions. SMS, push, and in-app share the remaining 12 partitions across their consumer groups — each consumer group reads all 24 partitions independently.

    Bottleneck analysis at 500K/min:

    Channel Max throughput per worker Workers needed Kafka partitions
    Email 50 sends/sec (SES rate limit) 167 → use 200 24 (shared)
    SMS 200 sends/sec (Twilio) 42 → use 50 24 (shared)
    Push 1,000 sends/sec (FCM batch API) 8.3 → use 10 24 (shared)
    In-App 2,000 writes/sec (Postgres) 4.2 → use 6 24 (shared)

    Email is the bottleneck. The solution isn't more partitions — it's multiple ESP accounts (SendGrid primary, SES fallback), each with their own rate limit, and distributing sends across accounts in a round-robin pattern within the email worker.


    What Goes Wrong in Production (And How to Handle It)

    1. Preference service is down

    Fail open (send the notification, skip preference check) for transactional notifications (password reset, fraud alert). Fail closed (drop the notification) for marketing content. The distinction is in the notification type's config.

    2. ESP (email service provider) outage

    The circuit breaker opens on the primary ESP. Workers automatically route to the secondary ESP. The DLQ processor drains backed-up notifications when the primary recovers.

    3. Template rendering fails for one user

    The worker sends the notification to the DLQ with error_type: TEMPLATE_RENDER_ERROR. The DLQ processor alerts engineering. The rest of the campaign continues — one bad template variable doesn't stop 9,999,999 other sends.

    4. Kafka consumer lag spikes

    Set an alert when consumer group lag exceeds 100,000 messages (about 12 seconds of peak throughput). Root cause is usually: a slow downstream (ESP rate-limited), workers processing too slowly (increase parallelism), or a Kafka partition imbalance (rebalance the consumer group).


    Spring Boot Wiring

    @Configuration
    class NotificationKafkaConfig {
    
        @Bean
        KafkaListenerContainerFactory<ConcurrentMessageListenerContainer<String, NotificationEvent>>
        emailKafkaListenerContainerFactory(ConsumerFactory<String, NotificationEvent> cf) {
            var factory = new ConcurrentKafkaListenerContainerFactory<String, NotificationEvent>();
            factory.setConsumerFactory(cf);
            factory.setConcurrency(12);  // 12 threads = 12 partitions for email group
            factory.getContainerProperties().setAckMode(AckMode.MANUAL_IMMEDIATE);
            factory.getContainerProperties().setPollTimeout(3_000);
    
            // Dead letter publishing: after 3 failed attempts, route to DLQ
            var recoverer = new DeadLetterPublishingRecoverer(kafkaTemplate,
                (rec, ex) -> new TopicPartition("notification-dlq", rec.partition()));
            var errorHandler = new DefaultErrorHandler(recoverer,
                new FixedBackOff(1_000L, 3L));
            factory.setCommonErrorHandler(errorHandler);
    
            return factory;
        }
    }

    Interview Cheat Sheet

    If you're preparing for system design interviews, here's what interviewers are listening for on this question:

    Topic Key point
    Fan-out mechanism Consumer groups on Kafka, not fan-out in the API
    Idempotency Dedup key in Redis, not in the database (too slow)
    Preference check Before the queue, not inside the worker
    Failure handling Three categories: success, retryable, permanent
    DLQ Separate topic, separate processor, not just retry in-place
    At-least-once Acceptable with dedup key; exactly-once is complexity with marginal gain
    Partition key recipientId for ordering guarantee per user
    Scale number Know your throughput math (events/min → events/sec → partitions needed)
    Database choice TimescaleDB for delivery log time-series, Redis for preferences cache

    The question interviewers are waiting for you to ask: "What's the SLA difference between transactional and marketing notifications?" Answering this before they prompt it shows you understand the business constraints that drive architecture, not just the technology.


    Notification systems are where distributed systems complexity meets business requirements that change constantly (new channels, new preference types, new regulatory requirements). The architecture above is designed to absorb those changes: add a new channel by adding a consumer group, add a new preference type by extending the Redis hash, add a new region by adding a Kafka cluster in that region.

    The design is the easy part. The hard part is the retries.


    Avaneesh Yadav is Engineering Manager at HashedIn by Deloitte, designing enterprise systems at scale. He writes about system design and AI architecture at buildingai.in.

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Was this helpful?
    AY
    Avaneesh Yadav

    I build enterprise AI systems — Spring AI, RAG, and agents — and write about shipping LLMs to production. I also run advisory and workshops for engineering teams.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Join engineers building production AI systems

    Free: LLM Production Readiness Checklist (PDF)

    50 checks across observability, rate limiting, cost optimization, failure handling, and security — for teams shipping AI features to production.

    No spam. Unsubscribe any time.

    Comments