Skip to content
Codeloom
System Design

Designing a Notification System

System design deep dive — multi-channel notification system with push, email, SMS, rate limiting, user preferences, and delivery guarantees.

·4 min read · By Codeloom
Intermediate 12 min read

What you'll learn

  • High-level architecture for a multi-channel notification system
  • Message queues and worker pools for reliable delivery
  • User preferences, rate limiting, and deduplication
  • Handling failures, retries, and delivery tracking

Prerequisites

  • Basic system design concepts (APIs, databases, queues)
  • Understanding of push notifications, email, and SMS

A notification system sends messages to users across multiple channels — push notifications, email, SMS, and in-app. At scale, it must be reliable, configurable, and respect user preferences. Here is how to design one.

Requirements

Functional:

  • Send notifications via push, email, SMS, and in-app
  • Users configure per-channel preferences
  • Support for immediate and scheduled notifications
  • Rate limiting to prevent spam
  • Delivery tracking and analytics

Non-functional:

  • High availability — notifications must not be lost
  • Low latency for real-time notifications
  • At-least-once delivery guarantee
  • Handle 10M+ notifications per day

High-level architecture

Client → API Server → Message Queue → Workers → Delivery Services
                    → Preferences DB       ↓
                    → Templates DB    Push / Email / SMS / In-App

                                    Delivery Tracker

Components

  1. API Server: accepts notification requests, validates, and enqueues
  2. Message Queue: buffers notifications for async processing (Kafka/SQS)
  3. Workers: consume from queue, apply preferences, render templates, route to channels
  4. Delivery Services: channel-specific senders (APNs, SMTP, Twilio)
  5. Preferences Service: stores user notification settings
  6. Template Service: renders notification content from templates

API design

POST /api/notifications
{
  "userId": "user-123",
  "type": "order_shipped",
  "data": {
    "orderId": "ord-456",
    "trackingUrl": "https://..."
  },
  "channels": ["push", "email"],  // optional override
  "scheduledAt": "2026-07-01T10:00:00Z"  // optional
}

Message queue flow

1. API validates request
2. Fetch user preferences → filter channels
3. For each active channel, enqueue a message:
   {
     userId, channel, type, data, templateId, priority
   }
4. Return 202 Accepted with notificationId

Using separate queues per channel allows independent scaling:

notification.push  → Push Workers  → APNs / FCM
notification.email → Email Workers → SES / SendGrid
notification.sms   → SMS Workers   → Twilio
notification.inapp → In-App Workers → WebSocket / DB

User preferences

CREATE TABLE notification_preferences (
    user_id     VARCHAR PRIMARY KEY,
    push        BOOLEAN DEFAULT true,
    email       BOOLEAN DEFAULT true,
    sms         BOOLEAN DEFAULT false,
    quiet_start TIME DEFAULT '22:00',
    quiet_end   TIME DEFAULT '08:00',
    frequency   VARCHAR DEFAULT 'immediate'  -- 'immediate', 'hourly_digest', 'daily_digest'
);

CREATE TABLE notification_opt_outs (
    user_id    VARCHAR,
    type       VARCHAR,  -- 'order_shipped', 'marketing', etc.
    channel    VARCHAR,  -- 'push', 'email', 'sms'
    PRIMARY KEY (user_id, type, channel)
);

Rate limiting

Prevent notification fatigue:

LIMITS = {
    "push": {"per_hour": 10, "per_day": 50},
    "email": {"per_hour": 5, "per_day": 20},
    "sms": {"per_hour": 2, "per_day": 5},
}

Use a sliding window counter in Redis:

def check_rate_limit(user_id: str, channel: str) -> bool:
    key = f"notif_rate:{user_id}:{channel}"
    count = redis.incr(key)
    if count == 1:
        redis.expire(key, 3600)
    return count <= LIMITS[channel]["per_hour"]

Deduplication

Prevent duplicate notifications using an idempotency key:

def is_duplicate(notification_id: str) -> bool:
    key = f"notif_dedup:{notification_id}"
    return not redis.set(key, 1, nx=True, ex=86400)

Template rendering

TEMPLATES = {
    "order_shipped": {
        "push": {
            "title": "Order Shipped!",
            "body": "Your order {{orderId}} is on its way.",
        },
        "email": {
            "subject": "Your order has shipped",
            "template": "order_shipped.html",
        },
    },
}

def render(type: str, channel: str, data: dict) -> dict:
    template = TEMPLATES[type][channel]
    return {
        k: v.replace("{{orderId}}", data.get("orderId", ""))
        for k, v in template.items()
    }

Failure handling and retries

MAX_RETRIES = 3
RETRY_DELAYS = [60, 300, 900]  # 1min, 5min, 15min

def process_notification(message):
    try:
        send(message)
        track_delivery(message, "delivered")
    except TemporaryError:
        retry_count = message.get("retryCount", 0)
        if retry_count < MAX_RETRIES:
            message["retryCount"] = retry_count + 1
            requeue_with_delay(message, RETRY_DELAYS[retry_count])
        else:
            track_delivery(message, "failed")
            alert_oncall(message)
    except PermanentError:
        track_delivery(message, "failed")

Delivery tracking

CREATE TABLE notification_log (
    id              UUID PRIMARY KEY,
    user_id         VARCHAR NOT NULL,
    channel         VARCHAR NOT NULL,
    type            VARCHAR NOT NULL,
    status          VARCHAR NOT NULL,  -- 'queued', 'sent', 'delivered', 'failed'
    created_at      TIMESTAMP,
    delivered_at    TIMESTAMP,
    failure_reason  TEXT
);

Summary

A notification system is a pipeline: validate → check preferences → rate limit → deduplicate → render → deliver → track. Use message queues for reliability, per-channel workers for independent scaling, Redis for rate limiting and deduplication, and a delivery log for observability. Start with two channels (push + email) and add SMS and in-app as needed.