Beginner
Messaging & Queues
20 min

Design a Notification System

A notification system delivers messages to users across multiple channels (push, email, SMS) reliably and at scale. The key challenges are multi-channel fan-out, per-user rate limiting to prevent spam, delivery tracking, and resilient retry logic so that transient provider failures never cause permanent message loss.

KafkaPriority QueuesRate LimitingRetryExponential BackoffTemplate Engine

Design it yourself

Don't just read it — drag components onto a canvas and get Aria's interviewer review.

Requirements

Functional

  • Send notifications via three channels: push (FCM/APNs), email (SendGrid/SES), and SMS (Twilio)
  • Clients submit a notification event; the system chooses the right channel(s) per user preference
  • Template engine — notifications are defined as templates with variable substitution (e.g. "Hi {{name}}, your order {{orderId}} has shipped")
  • Priority levels — CRITICAL (OTP, security alerts) vs MARKETING (promotions) with separate processing queues
  • Per-user rate limiting — no more than N notifications per channel per time window
  • Delivery tracking — record whether each notification was sent, delivered, opened, or failed
  • Retry with exponential backoff for transient provider failures

Non-Functional

  • 10M notifications/day ≈ 116/sec average, 500/sec at peak
  • Split across channels: 60% push, 30% email, 10% SMS
  • CRITICAL notifications must be delivered within 5 seconds end-to-end
  • MARKETING notifications can tolerate up to 60 seconds of delay
  • 99.9% delivery rate — at most 10,000 permanent failures per day
  • System must handle provider outages gracefully (retry, fallback)

Capacity Estimation

Notifications / day10M ≈ 116 / sec (peak ~500 / sec)
Push (60%)6M / day ≈ 70 / sec
Email (30%)3M / day ≈ 35 / sec
SMS (10%)1M / day ≈ 12 / sec
Delivery record size~200 bytes per notification
Storage / day10M × 200B = 2 GB / day
Storage (1 year)~730 GB (before archival)

High-Level Components

Notification API

REST API that accepts notification events from upstream services (order service, auth service, marketing platform). Validates the payload, resolves the template, applies rate limiting, and enqueues to the appropriate Kafka topic.

Template Service

Stores notification templates in a database with variable placeholders. Renders a template by substituting variables from the event payload. Caches rendered templates for repeated sends (e.g. bulk marketing campaigns).

Priority Kafka Topics

Two Kafka topics per channel: notifications-push-critical, notifications-push-marketing, notifications-email-critical, notifications-email-marketing, notifications-sms-critical, notifications-sms-marketing. Critical topics have more partitions and dedicated consumers for lower latency.

Channel Workers (Push / Email / SMS)

Kafka consumers per channel that read from their topic, call the third-party provider API (FCM, SendGrid, Twilio), and update the delivery record. Each worker maintains a circuit breaker per provider.

Rate Limiter

Redis-backed sliding window rate limiter. Enforces per-user, per-channel limits (e.g. max 10 marketing push notifications per day). Invoked by the Notification API before enqueuing; over-limit events are dropped or deferred.

Delivery Tracker

Stores the lifecycle of every notification: QUEUED → SENT → DELIVERED / FAILED. Receives webhook callbacks from providers (e.g. FCM delivery receipts, SendGrid events). Exposes a query API for dashboards and debugging.

Retry Worker

Consumes FAILED events from a dead-letter topic. Applies exponential backoff with jitter before re-enqueueing. After a configurable maximum attempts, marks the notification as PERMANENTLY_FAILED and triggers an alert.

User Preference Service

Stores per-user channel preferences and opt-outs. The Notification API consults this before routing: if a user has disabled marketing SMS, skip that channel. Backed by a replicated database with an in-process cache.

Architecture Diagram

Rendering diagram…

Deep Dives

Notification Pipeline

The end-to-end pipeline from upstream event to delivered notification:

1. Ingest: Upstream service (e.g. Order Service) calls `POST /notifications` with `{userId, templateId, variables, priority, channels}`.

2. Validate & enrich: API validates the payload, fetches the user's device tokens and email address from the User Service, and resolves channel preferences.

3. Rate limit check: For each channel, check Redis sliding window. If the user is over-limit for a channel, skip that channel (MARKETING) or log a warning (CRITICAL — rate limits are advisory for critical notifications).

4. Template render: Template Service substitutes variables into the template body and subject.

5. Enqueue: One Kafka message per channel. The message contains the rendered content, recipient address, and notification ID.

6. Deliver: Channel Worker consumes the message, calls the provider API, and records the result.

7. Track: Provider webhooks (or polling for providers without webhooks) update the delivery status in the Delivery Tracker.

Java — Notification API orchestration: validate → rate-limit → template → enqueue

// Notification API — orchestration entry point
@RestController
@RequestMapping("/notifications")
public class NotificationController {

    @PostMapping
    public ResponseEntity<NotificationResponse> send(@RequestBody NotificationRequest req) {
        // 1. Resolve user contact details and preferences
        UserContact contact = userService.getContact(req.userId());
        Set<Channel> allowedChannels = preferenceService
            .filterChannels(req.userId(), req.channels(), req.priority());

        // 2. Render template once, reuse for all channels
        RenderedTemplate rendered = templateService.render(req.templateId(), req.variables());

        String notificationId = UUID.randomUUID().toString();
        List<ChannelEnqueueResult> results = new ArrayList<>();

        for (Channel channel : allowedChannels) {
            // 3. Per-user, per-channel rate limiting
            if (!rateLimiter.tryAcquire(req.userId(), channel, req.priority())) {
                results.add(new ChannelEnqueueResult(channel, "RATE_LIMITED"));
                continue;
            }

            // 4. Build channel-specific message and publish to Kafka
            NotificationMessage msg = NotificationMessage.builder()
                .notificationId(notificationId)
                .userId(req.userId())
                .channel(channel)
                .priority(req.priority())
                .recipient(contact.recipientFor(channel))
                .subject(rendered.subject())
                .body(rendered.body())
                .build();

            String topic = "notifications-" + channel.name().toLowerCase()
                         + "-" + req.priority().name().toLowerCase();
            kafkaTemplate.send(topic, req.userId(), msg);
            deliveryTracker.record(notificationId, channel, Status.QUEUED);
            results.add(new ChannelEnqueueResult(channel, "QUEUED"));
        }

        return ResponseEntity.accepted()
            .body(new NotificationResponse(notificationId, results));
    }
}

Per-User Rate Limiting

Without rate limiting, a buggy upstream service can spam a user with thousands of notifications in minutes. A sliding window counter per user+channel is the right primitive.

Sliding window log (exact): Store each notification timestamp in a Redis sorted set per user+channel. On each request: ZREMRANGEBYSCORE to drop entries older than the window, ZCARD to get the current count, and ZADD to add the new timestamp. Atomic via Lua script. Accurate but uses O(N) memory per user where N is the window size in events.

Sliding window counter (approximate): Track two fixed time buckets (current minute and previous minute). Estimated count = previousCount × (1 − elapsed/windowSize) + currentCount. Constant memory per user. Accurate to within ~1%.

Implementation: Use Redis with a Lua script so the check and increment are atomic. Keys expire automatically after the window.

Priority override: CRITICAL notifications (OTPs, security alerts) have a much higher rate limit or no limit. Enforce by checking priority before the rate limiter.

Java — Redis sorted-set sliding window rate limiter with Lua atomicity

@Component
public class SlidingWindowRateLimiter {

    private final RedisTemplate<String, String> redis;

    // Lua script: atomically check + increment sliding window
    private static final String RATE_LIMIT_SCRIPT = """
        local key = KEYS[1]
        local now = tonumber(ARGV[1])
        local window = tonumber(ARGV[2])
        local limit = tonumber(ARGV[3])
        local clearBefore = now - window

        -- Remove entries outside the window
        redis.call('ZREMRANGEBYSCORE', key, '-inf', clearBefore)

        -- Count current entries
        local count = redis.call('ZCARD', key)
        if count >= limit then
            return 0  -- rate limited
        end

        -- Add current request timestamp
        redis.call('ZADD', key, now, now .. '-' .. math.random())
        redis.call('EXPIRE', key, window / 1000 + 1)
        return 1  -- allowed
        """;

    public boolean tryAcquire(String userId, Channel channel, Priority priority) {
        // CRITICAL notifications: 100/day per channel; MARKETING: 5/day
        int limit = priority == Priority.CRITICAL ? 100 : 5;
        long windowMs = Duration.ofDays(1).toMillis();
        String key = "ratelimit:" + userId + ":" + channel.name();

        Long result = redis.execute(
            new DefaultRedisScript<>(RATE_LIMIT_SCRIPT, Long.class),
            List.of(key),
            String.valueOf(System.currentTimeMillis()),
            String.valueOf(windowMs),
            String.valueOf(limit)
        );
        return Long.valueOf(1L).equals(result);
    }
}

Retry with Exponential Backoff

Provider APIs (FCM, SendGrid, Twilio) have transient failures: rate limits (HTTP 429), temporary outages (HTTP 503), and network timeouts. A retry strategy must be:

Exponential backoff: Wait time doubles on each retry — 1s, 2s, 4s, 8s, 16s. This prevents thundering herd where all retries fire simultaneously after an outage.

Jitter: Add random jitter (±30%) to the backoff so retries from many workers are spread over time: `delay = baseDelay × 2^attempt × (0.7 + 0.6 × random())`.

Dead-letter topic: After a configurable max attempts (e.g. 5), move the message to a dead-letter Kafka topic (`notifications-dlq`). A separate DLQ worker monitors this topic, alerts on-call, and can replay after the provider recovers.

Circuit breaker: Wrap each provider call in a circuit breaker (Resilience4j). If >50% of calls fail in a 30-second window, open the circuit — fail fast and route to the DLQ instead of hammering a down provider.

Idempotency: Channel Workers must be idempotent. If a message is retried, do not deliver the notification twice. Use the notificationId as an idempotency key at the provider level (FCM supports this natively; for email/SMS, check the delivery tracker before sending).

Java — Push channel worker with exponential backoff, jitter, circuit breaker, and DLQ

@Service
public class PushChannelWorker {

    private static final int MAX_ATTEMPTS = 5;
    private final FcmClient fcm;
    private final DeliveryTracker tracker;
    private final KafkaTemplate<String, NotificationMessage> kafka;
    private final CircuitBreaker circuitBreaker;

    @KafkaListener(topics = {"notifications-push-critical", "notifications-push-marketing"})
    public void consume(NotificationMessage msg) {
        // Idempotency guard — skip if already delivered
        if (tracker.isDelivered(msg.notificationId(), Channel.PUSH)) return;

        deliver(msg, 1);
    }

    private void deliver(NotificationMessage msg, int attempt) {
        try {
            circuitBreaker.executeRunnable(() -> {
                FcmResponse response = fcm.send(msg.recipient(), msg.subject(), msg.body(),
                    Map.of("notificationId", msg.notificationId())); // idempotency key
                tracker.markSent(msg.notificationId(), Channel.PUSH);
            });
        } catch (CallNotPermittedException e) {
            // Circuit breaker open — send to DLQ immediately
            sendToDlq(msg, "CIRCUIT_OPEN");
        } catch (Exception e) {
            if (attempt >= MAX_ATTEMPTS) {
                tracker.markFailed(msg.notificationId(), Channel.PUSH, e.getMessage());
                sendToDlq(msg, "MAX_RETRIES_EXCEEDED");
                return;
            }
            long delayMs = computeBackoff(attempt);
            log.warn("Push delivery failed (attempt {}), retrying in {}ms: {}", attempt, delayMs, e.getMessage());
            scheduleRetry(msg, attempt + 1, delayMs);
        }
    }

    private long computeBackoff(int attempt) {
        double base = 1000L * Math.pow(2, attempt - 1);  // 1s, 2s, 4s, 8s, 16s
        double jitter = 0.7 + (Math.random() * 0.6);      // ±30% jitter
        return (long) (base * jitter);
    }

    private void scheduleRetry(NotificationMessage msg, int nextAttempt, long delayMs) {
        // Use a delayed queue (Kafka with delayed topic or separate scheduler)
        NotificationMessage retryMsg = msg.withAttempt(nextAttempt);
        kafka.send("notifications-push-retry", retryMsg);
    }

    private void sendToDlq(NotificationMessage msg, String reason) {
        kafka.send("notifications-dlq", msg.withFailureReason(reason));
    }
}

Delivery Tracking

Delivery tracking gives observability into the health of the notification pipeline and enables debugging when users report missing notifications.

States per notification per channel: `QUEUED → SENT → DELIVERED → OPENED` (success path) `QUEUED → SENT → FAILED` (provider rejection) `QUEUED → PERMANENTLY_FAILED` (max retries exhausted)

Provider webhooks: Most providers support webhooks for delivery receipts: - FCM: upstream_message_id + delivery timestamp - SendGrid: email open and click events - Twilio: SMS status callbacks

A dedicated Webhook Receiver service handles provider callbacks, validates the signature (HMAC), and updates the delivery record.

Storage: Use a time-series-friendly table in PostgreSQL (or a write-optimised NoSQL store like Cassandra). Partition by `created_date` for efficient archival. Index on `(notification_id, channel)` for point lookups and `(user_id, created_at)` for user-level history queries.

Metrics: Export aggregated metrics to a time-series store (Prometheus/InfluxDB): delivery rate per channel, average latency, failure rate per provider. Alert if the delivery rate for CRITICAL notifications drops below 99.5%.

Java — Delivery tracker with upsert, webhook handler, idempotency check, and stats query

// Delivery Tracker — write and query
@Repository
public class DeliveryTrackerRepository {

    private final JdbcTemplate jdbc;

    // Called by Channel Worker on each state transition
    public void updateStatus(String notificationId, Channel channel,
                              DeliveryStatus status, String detail) {
        jdbc.update("""
            INSERT INTO notification_delivery
                (notification_id, channel, status, detail, updated_at)
            VALUES (?, ?, ?, ?, NOW())
            ON CONFLICT (notification_id, channel)
            DO UPDATE SET status = EXCLUDED.status,
                          detail = EXCLUDED.detail,
                          updated_at = EXCLUDED.updated_at
            """,
            notificationId, channel.name(), status.name(), detail
        );
    }

    // Called by Webhook Receiver when provider sends delivery receipt
    public void markDelivered(String providerMessageId, Channel channel, Instant deliveredAt) {
        jdbc.update("""
            UPDATE notification_delivery
            SET status = 'DELIVERED', delivered_at = ?, updated_at = NOW()
            WHERE provider_message_id = ? AND channel = ?
            """,
            Timestamp.from(deliveredAt), providerMessageId, channel.name()
        );
    }

    public boolean isDelivered(String notificationId, Channel channel) {
        Integer count = jdbc.queryForObject("""
            SELECT COUNT(*) FROM notification_delivery
            WHERE notification_id = ? AND channel = ? AND status = 'DELIVERED'
            """,
            Integer.class, notificationId, channel.name()
        );
        return count != null && count > 0;
    }

    // Dashboard: delivery stats for last 24 hours per channel
    public List<DeliveryStats> getDailyStats() {
        return jdbc.query("""
            SELECT channel,
                   COUNT(*) FILTER (WHERE status = 'DELIVERED') AS delivered,
                   COUNT(*) FILTER (WHERE status = 'PERMANENTLY_FAILED') AS failed,
                   AVG(EXTRACT(EPOCH FROM (delivered_at - queued_at))) AS avg_latency_secs
            FROM notification_delivery
            WHERE queued_at > NOW() - INTERVAL '24 hours'
            GROUP BY channel
            """,
            (rs, row) -> new DeliveryStats(
                Channel.valueOf(rs.getString("channel")),
                rs.getLong("delivered"),
                rs.getLong("failed"),
                rs.getDouble("avg_latency_secs")
            )
        );
    }
}

Key Trade-offs

Single notification queue vs per-channel, per-priority queues

Per-channel, per-priority Kafka topics

A single queue means a marketing email backlog can delay OTP delivery. Separate topics give CRITICAL notifications dedicated consumer groups and more partitions, guaranteeing their latency SLA is met independently.

Synchronous delivery vs async via Kafka

Async via Kafka

Calling FCM/SendGrid/Twilio synchronously in the API request would tie API latency to provider latency. Kafka decouples ingest from delivery — the API responds in <10ms, and delivery happens asynchronously at provider speed.

Fixed retry interval vs exponential backoff with jitter

Exponential backoff with jitter

Fixed interval retries cause a thundering herd — all failed messages retry simultaneously after a provider outage, often overwhelming the newly-recovered provider. Exponential backoff with jitter spreads retries over time.

Token bucket vs sliding window for rate limiting

Sliding window (sorted set in Redis)

Token bucket allows a burst at the window boundary (N tokens at minute 0:59, another N at 1:00 = 2N in ~2 seconds). Sliding window distributes the limit smoothly across the window with no boundary bursting.

Push-only vs multi-channel fan-out

Multi-channel with user preference routing

Delivery depends on the device being online, notification permissions, and carrier reliability. Routing to the best channel per user (and falling back on failure) maximises the probability of the message being seen.

Interview Tips

  • 1Start with the three channels (push, email, SMS) and immediately introduce the separate priority queues. This shows you understand that a marketing blast should never delay an OTP.
  • 2The rate-limiting question almost always comes up. Know the difference between token bucket and sliding window and why sliding window avoids boundary bursts.
  • 3Retry logic is a key deep-dive. State "exponential backoff with jitter" — and explain that jitter prevents the thundering herd after a provider outage.
  • 4Mention idempotency keys before the interviewer asks. Channel workers must not double-deliver on retry — check the delivery tracker or pass a provider idempotency key.
  • 5Template rendering: note that templates are rendered once by the API before enqueuing, not inside the worker. This avoids hitting the template database for every retry.
  • 6Bring up circuit breakers around third-party providers. Resilience4j is a good Java reference. This shows production engineering thinking.
  • 7Clarify scale early: 10M/day ≈ 116/sec is moderate — a single well-configured Kafka cluster handles this easily. The real challenge is delivery tracking storage and provider reliability, not raw throughput.

Discussion

Discussion

Sign in to join the discussion.

Loading discussion…