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.