Notification Dispatch System Low-Level Design: Multi-Channel Routing and Preferences

A notification dispatch system routes outbound notifications — email, SMS, push, in-app — to the right channels based on user preferences, respects quiet hours and frequency caps, and delivers reliably at scale. The dispatch layer sits between the event source (payment succeeded, new follower) and the delivery providers (SendGrid, Twilio, FCM), adding preference filtering, deduplication, and retry logic that would otherwise have to be implemented in every service that sends notifications.

Core Data Model

CREATE TABLE NotificationRequest (
    request_id      UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id         BIGINT NOT NULL,
    event_type      VARCHAR(100) NOT NULL,   -- 'payment.succeeded', 'new_follower'
    template_id     VARCHAR(100) NOT NULL,
    payload         JSONB NOT NULL,          -- template variables
    priority        VARCHAR(10) NOT NULL DEFAULT 'normal', -- 'critical', 'normal', 'low'
    requested_channels TEXT[] NOT NULL,      -- ['email', 'push', 'sms']
    idempotency_key VARCHAR(200) UNIQUE,     -- prevents duplicate sends on retry
    status          VARCHAR(20) NOT NULL DEFAULT 'pending',
    created_at      TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE NotificationDelivery (
    delivery_id     UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    request_id      UUID NOT NULL REFERENCES NotificationRequest(request_id),
    channel         VARCHAR(20) NOT NULL,    -- 'email', 'push', 'sms', 'in_app'
    recipient       VARCHAR(500) NOT NULL,   -- email address, phone, device token
    status          VARCHAR(20) NOT NULL DEFAULT 'pending',
    -- pending, sent, delivered, failed, suppressed
    provider_id     VARCHAR(200),            -- external message ID
    attempt_count   INT NOT NULL DEFAULT 0,
    sent_at         TIMESTAMPTZ,
    failed_at       TIMESTAMPTZ,
    error_message   TEXT
);

CREATE TABLE NotificationPreference (
    user_id         BIGINT NOT NULL,
    event_type      VARCHAR(100) NOT NULL,
    channel         VARCHAR(20) NOT NULL,
    enabled         BOOLEAN NOT NULL DEFAULT TRUE,
    quiet_hours_start TIME,
    quiet_hours_end   TIME,
    timezone        VARCHAR(50) NOT NULL DEFAULT 'UTC',
    frequency_cap   INT,                    -- max per day, NULL = no cap
    PRIMARY KEY (user_id, event_type, channel)
);

CREATE TABLE NotificationFrequency (
    user_id     BIGINT NOT NULL,
    channel     VARCHAR(20) NOT NULL,
    event_type  VARCHAR(100) NOT NULL,
    date        DATE NOT NULL,
    count       INT NOT NULL DEFAULT 0,
    PRIMARY KEY (user_id, channel, event_type, date)
);

Dispatch Pipeline

def dispatch_notification(request_id: str):
    request = db.fetchone("SELECT * FROM NotificationRequest WHERE request_id=%s", [request_id])
    if not request or request['status'] != 'pending':
        return

    # Load user preferences for all requested channels
    prefs = load_preferences(request['user_id'], request['event_type'],
                             request['requested_channels'])

    deliveries_to_create = []

    for channel in request['requested_channels']:
        pref = prefs.get(channel)

        # 1. Preference check
        if not pref or not pref['enabled']:
            log_suppression(request_id, channel, 'preference_disabled')
            continue

        # 2. Quiet hours check
        if is_in_quiet_hours(pref):
            if request['priority'] == 'critical':
                pass  # critical bypasses quiet hours
            else:
                schedule_for_after_quiet_hours(request_id, channel, pref)
                continue

        # 3. Frequency cap check
        if pref['frequency_cap']:
            daily_count = get_daily_count(request['user_id'], channel,
                                          request['event_type'])
            if daily_count >= pref['frequency_cap']:
                log_suppression(request_id, channel, 'frequency_cap')
                continue

        # 4. Get recipient address for channel
        recipient = get_channel_recipient(request['user_id'], channel)
        if not recipient:
            log_suppression(request_id, channel, 'no_address')
            continue

        deliveries_to_create.append({
            'request_id': request_id,
            'channel': channel,
            'recipient': recipient,
        })

    # Create delivery records and enqueue sends
    for d in deliveries_to_create:
        delivery_id = create_delivery_record(d)
        send_queue.enqueue(f'send_{d["channel"]}', delivery_id=delivery_id)
        increment_frequency_count(request['user_id'], d['channel'],
                                  request['event_type'])

    db.execute("""
        UPDATE NotificationRequest SET status='dispatched' WHERE request_id=%s
    """, [request_id])

Channel Send Workers

def send_email_notification(delivery_id: str):
    delivery = db.fetchone("SELECT * FROM NotificationDelivery WHERE delivery_id=%s",
                           [delivery_id])
    request = db.fetchone("SELECT * FROM NotificationRequest WHERE request_id=%s",
                          [delivery['request_id']])

    template = load_template(request['template_id'], 'email')
    rendered = render_template(template, request['payload'])

    try:
        msg_id = sendgrid.send(
            to=delivery['recipient'],
            subject=rendered['subject'],
            html=rendered['html'],
            idempotency_key=str(delivery_id)  # prevent duplicate sends on retry
        )
        db.execute("""
            UPDATE NotificationDelivery
            SET status='sent', provider_id=%s, sent_at=NOW(), attempt_count=attempt_count+1
            WHERE delivery_id=%s
        """, [msg_id, delivery_id])
    except Exception as e:
        db.execute("""
            UPDATE NotificationDelivery
            SET status='failed', error_message=%s, failed_at=NOW(), attempt_count=attempt_count+1
            WHERE delivery_id=%s
        """, [str(e), delivery_id])
        if delivery['attempt_count'] < 3:
            send_queue.enqueue_with_delay(
                'send_email_notification',
                delay=60 * (2 ** delivery['attempt_count']),
                delivery_id=delivery_id
            )

Key Interview Points

  • The idempotency_key on NotificationRequest prevents duplicate sends when the caller retries a failed enqueue — same event processed twice results in one notification, not two.
  • Channel suppression reasons (preference_disabled, frequency_cap, quiet_hours, no_address) are logged for debugging — “why didn’t I get a notification?” is the most common support question.
  • Frequency caps prevent notification fatigue: a “someone liked your post” notification capped at 3/day means a viral post doesn’t spam the author with hundreds of push notifications.
  • Critical priority notifications (security alerts, password reset, payment failure) should bypass quiet hours and frequency caps — the user needs them regardless.
  • Separate queues per channel and per priority: critical email should not queue behind 10,000 low-priority marketing pushes. Use a high-priority queue for critical/transactional and a standard queue for marketing.
  • Template rendering is separate from sending — render at dispatch time (captures the payload values at the moment the event occurred) rather than at send time (payload values may have changed).

Notification dispatch and multi-channel routing system design is discussed in Uber system design interview questions.

Notification dispatch and user preference filtering design is covered in Airbnb system design interview preparation.

Notification dispatch and frequency cap system design is discussed in LinkedIn system design interview guide.

Scroll to Top