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).

{“@context”:”https://schema.org”,”@type”:”FAQPage”,”mainEntity”:[{“@type”:”Question”,”name”:”How does a frequency cap prevent notification fatigue?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Frequency caps limit how many notifications a user receives per channel per time window — e.g., at most 3 push notifications per day, 1 email per hour, 5 in-app alerts per 24 hours. Without caps, a burst of upstream events (10 friends joined at once, 5 orders shipped) generates 10+ notifications in seconds, causing users to disable all notifications. Implementation: a NotificationFrequency table (user_id, channel, window_start, count). Before dispatching: SELECT count FROM NotificationFrequency WHERE user_id=X AND channel=Y AND window_start >= NOW()-interval. If count >= limit, enqueue for the next window instead of sending now. Use INSERT … ON CONFLICT (user_id, channel, window_start) DO UPDATE SET count = count + 1 to atomically increment the counter. Hourly windows reset automatically as window_start shifts.”}},{“@type”:”Question”,”name”:”How do you handle quiet hours across different user timezones?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Quiet hours (e.g., no notifications between 10pm and 8am local time) must be enforced in the user’s local timezone, not UTC. Store user_timezone as an IANA timezone string (America/New_York, Asia/Tokyo). At dispatch time: compute the user’s local hour using their timezone. If current local hour falls in the quiet window, don’t send — instead, enqueue the notification for delivery at the quiet hours end time (8am local = 8am user_tz converted to UTC). Use Python’s datetime.now(ZoneInfo(user_timezone)) to get the local time. For users with no timezone set, use UTC as the default or prompt them to set it during onboarding. Quiet hours for marketing notifications; urgent notifications (security alerts) bypass quiet hours with a notification_priority field.”}},{“@type”:”Question”,”name”:”How do you route a notification to the right channel based on user preferences?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”NotificationPreference table stores (user_id, notification_type, channel, is_enabled) per notification category. A notification_type might be "order_shipped" — some users want email only, others want push + email, others have turned it off entirely. The dispatch function filters by preference before sending: fetch all preferences for (user_id, notification_type), iterate over channels where is_enabled=TRUE. If no preference rows exist, use the system default (send via push if the user has a push token). Channel priority: if a push token is not registered (user hasn’t used the mobile app), fall back to email. Log which channel was used in NotificationDelivery for analytics: "80% of order_shipped notifications are delivered via email because only 20% of users have push tokens."”}},{“@type”:”Question”,”name”:”How do you implement exponential backoff retry for failed notification deliveries?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Each channel worker (push, email, SMS) wraps the external API call in a retry loop with exponential backoff. Retry schedule: attempt 1 at T+0, attempt 2 at T+30s, attempt 3 at T+2m, attempt 4 at T+8m, attempt 5 at T+32m, then give up and mark as "failed." Store retry_count and next_attempt_at in NotificationDelivery. A background worker queries: SELECT * FROM NotificationDelivery WHERE status=’retrying’ AND next_attempt_at <= NOW(). On each retry failure: UPDATE SET retry_count=retry_count+1, next_attempt_at=NOW() + interval ’30 seconds’ * power(4, retry_count). Max retries = 5; after that set status=’failed’ and trigger an alert if failure rate exceeds 5% of a notification type.”}},{“@type”:”Question”,”name”:”How do you deduplicate notifications to prevent sending the same alert twice?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Each NotificationRequest has an idempotency_key — a business-level identifier like "order_shipped:{order_id}" or "friend_joined:{user_id}:{friend_id}". Before processing: SELECT 1 FROM NotificationRequest WHERE idempotency_key=X. If a row exists, skip. If not, insert with status=’pending’ — using INSERT … ON CONFLICT (idempotency_key) DO NOTHING to handle races between concurrent workers. This database-level dedup survives Kafka message duplication and consumer restarts. The idempotency_key must be constructed by the event producer — the notification service cannot infer it. For timed notifications (daily digest), use "daily_digest:{user_id}:{date}" as the key.”}}]}

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