Home System Design Design a Notification System: Build One That Won't Collapse at 3 AM
Advanced 3 min · July 08, 2026

Design a Notification System: Build One That Won't Collapse at 3 AM

Design a notification system that handles millions of messages without falling over.

N
Naren Founder & Principal Engineer

20+ years shipping large-scale distributed systems. Notes here come from systems that actually shipped.

Follow
Production
production tested
July 08, 2026
last updated
1,697
articles · all by Naren
Before you start⏱ 30 min
  • Basic understanding of web services (REST APIs, HTTP)
  • Familiarity with queues (e.g., RabbitMQ, Kafka) at a conceptual level
  • Some experience with databases (SQL or NoSQL)
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer

Design a notification system by separating concerns: ingestion (accept notifications), processing (format, route), delivery (send via channels), and storage (log for audit). Use a queue to buffer spikes, a database for state, and idempotency keys to prevent duplicates.

✦ Definition~90s read
What is Design a Notification System?

A notification system delivers timely messages (email, SMS, push) to users based on triggers. It decouples message generation from delivery, handles retries, and scales to millions of events without losing data.

Think of a notification system like a post office.
Plain-English First

Think of a notification system like a post office. You drop a letter (notification) in a mailbox (API). The post office sorts it (processing), puts it on the right truck (queue), and delivers it (email/SMS/push). If a truck breaks down, they retry later. Without the post office, you'd have to hand-deliver every letter yourself — and if you got 10,000 letters at once, you'd be buried.

Ever been woken up at 3 AM because your notification system sent 50,000 duplicate emails to users? I have. The root cause? A naive design that treated notifications as synchronous HTTP calls. Here's the blunt truth: most notification systems fail because they ignore the hard parts — reliability, ordering, and idempotency. This article walks you through building one that survives production. By the end, you'll be able to design a system that handles millions of notifications per day, retries failures gracefully, and doesn't lose a single message.

Why Notifications Need Their Own System

You could send emails directly from your web server. That works until your server blocks on a slow SMTP call and your whole app freezes. Or until a marketing campaign triggers 100K emails and your database connection pool exhausts. A notification system decouples sending from your main app. It's a buffer that absorbs spikes, retries failures, and lets your app respond fast. Without it, you're one viral tweet away from a production outage.

Production Trap:
Never send notifications synchronously in your request-response cycle. A slow email provider can block your entire web server. Always use a queue.

Core Components: The Post Office Analogy

A notification system has four parts: Ingestion (the mailbox), Processing (the sorting office), Delivery (the trucks), and Storage (the records). Ingestion is a simple API that accepts a notification request and writes it to a queue. Processing reads from the queue, formats the message (HTML email, SMS text, push payload), and routes it to the right channel. Delivery actually sends it via a provider (SendGrid, Twilio, Firebase). Storage logs every notification for audit and debugging.

NotificationSystemArchitecture.systemdesignSYSTEMDESIGN
1
2
3
4
5
6
7
// io.thecodeforge — System Design tutorial

// High-level architecture diagram (pseudocode)
// Client -> API Gateway -> Notification Service -> Queue -> Worker -> Provider -> User
//                                           |-> Database (for logging)

// Key: Queue is the heart. Without it, the system is fragile.
Output
No output — architecture diagram.

Ingestion API: Accept Notifications Without Breaking

The ingestion API is a simple POST endpoint. It validates the request, assigns a unique idempotency key (if not provided), and writes to a queue. Never write to the database synchronously here — that's the worker's job. The API should return 202 Accepted immediately. This keeps your web server responsive. The classic mistake: blocking on a database write or a queue publish that fails. Use async writes with retries.

ingestion_api.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# io.thecodeforge — System Design tutorial

from flask import Flask, request, jsonify
from uuid import uuid4
import boto3

app = Flask(__name__)
sqs = boto3.client('sqs', region_name='us-east-1')
QUEUE_URL = 'https://sqs.us-east-1.amazonaws.com/123456789012/notifications'

def generate_idempotency_key():
    return str(uuid4())

@app.route('/notifications', methods=['POST'])
def ingest_notification():
    data = request.get_json()
    # Validate required fields
    if not data.get('user_id') or not data.get('message'):
        return jsonify({'error': 'user_id and message required'}), 400
    
    # Assign idempotency key if missing
    if 'idempotency_key' not in data:
        data['idempotency_key'] = generate_idempotency_key()
    
    # Send to queue asynchronously
    try:
        sqs.send_message(
            QueueUrl=QUEUE_URL,
            MessageBody=json.dumps(data),
            MessageDeduplicationId=data['idempotency_key']  # for FIFO queues
        )
    except Exception as e:
        # Log and return 500 — but don't block the client forever
        app.logger.error(f'Failed to enqueue: {e}')
        return jsonify({'error': 'internal error'}), 500
    
    return jsonify({'status': 'accepted', 'idempotency_key': data['idempotency_key']}), 202
Output
HTTP 202 Accepted with idempotency key.
Senior Shortcut:
Use a FIFO queue (like SQS FIFO) with content-based deduplication. This automatically prevents duplicate messages without extra code.

Processing Worker: Format and Route

The worker pulls messages from the queue, formats them for each channel, and sends them to the delivery layer. This is where you handle templates, localization, and channel selection. Keep workers stateless — all state lives in the message and a database. If a worker crashes, another picks up the message (thanks to queue visibility timeout). The gotcha: workers must handle poison pills — messages that always fail. Move them to a dead-letter queue after 3 retries.

worker.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
# io.thecodeforge — System Design tutorial

import json
import boto3
from time import sleep

sqs = boto3.client('sqs')
QUEUE_URL = 'https://sqs.us-east-1.amazonaws.com/123456789012/notifications'
DLQ_URL = 'https://sqs.us-east-1.amazonaws.com/123456789012/notifications-dlq'

def process_message(message):
    body = json.loads(message['Body'])
    user_id = body['user_id']
    channel = body.get('channel', 'email')  # default to email
    
    # Format based on channel
    if channel == 'email':
        formatted = format_email(body)
        send_email(user_id, formatted)
    elif channel == 'sms':
        formatted = format_sms(body)
        send_sms(user_id, formatted)
    # ... other channels
    
    # Log to database (async, fire-and-forget)
    log_notification(user_id, body['idempotency_key'], channel, 'sent')

def main():
    while True:
        response = sqs.receive_message(
            QueueUrl=QUEUE_URL,
            MaxNumberOfMessages=10,
            VisibilityTimeout=30,
            WaitTimeSeconds=20  # long polling
        )
        messages = response.get('Messages', [])
        for msg in messages:
            try:
                process_message(msg)
                sqs.delete_message(QueueUrl=QUEUE_URL, ReceiptHandle=msg['ReceiptHandle'])
            except Exception as e:
                # Check retry count from ApproximateReceiveCount
                receive_count = int(msg['Attributes']['ApproximateReceiveCount'])
                if receive_count >= 3:
                    # Move to DLQ
                    sqs.send_message(QueueUrl=DLQ_URL, MessageBody=msg['Body'])
                    sqs.delete_message(QueueUrl=QUEUE_URL, ReceiptHandle=msg['ReceiptHandle'])
                else:
                    # Let visibility timeout expire for retry
                    pass

if __name__ == '__main__':
    main()
Output
Worker runs continuously, processing messages. Failed messages go to DLQ after 3 retries.
The Classic Bug:

Delivery Layer: Talk to Providers Safely

Delivery is the most failure-prone part. Email providers rate-limit you. SMS gateways time out. Push notification services have outages. The delivery layer must handle retries with exponential backoff and jitter. Never retry immediately — you'll hammer the provider and make things worse. Also, track delivery status per provider: success, failed, bounced, etc. This data is gold for debugging.

delivery.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# io.thecodeforge — System Design tutorial

import time
import random
import requests

def send_email_with_retry(to, subject, body, max_retries=3):
    for attempt in range(max_retries):
        try:
            response = requests.post(
                'https://api.sendgrid.com/v3/mail/send',
                json={'personalizations': [{'to': [{'email': to}]}],
                      'subject': subject,
                      'content': [{'type': 'text/plain', 'value': body}]},
                headers={'Authorization': 'Bearer SG_API_KEY'},
                timeout=10
            )
            if response.status_code == 202:
                return True
            elif response.status_code == 429:  # rate limited
                wait = (2 ** attempt) + random.uniform(0, 1)  # exponential backoff + jitter
                time.sleep(wait)
            else:
                # Non-retryable error? Log and break.
                return False
        except requests.exceptions.Timeout:
            wait = (2 ** attempt) + random.uniform(0, 1)
            time.sleep(wait)
        except requests.exceptions.ConnectionError:
            wait = (2 ** attempt) + random.uniform(0, 1)
            time.sleep(wait)
    return False
Output
Returns True if sent, False after exhausting retries.
Interview Gold:
Always add jitter to your backoff. Without jitter, all retries happen at the same time, creating thundering herd problems. random.uniform(0, 1) is your friend.

Storage: Log Everything for Debugging

Every notification attempt should be logged: user_id, channel, idempotency_key, status, timestamp, error message. This is your audit trail. Use a separate database or a time-series store (like Elasticsearch) for logs. Don't use your main transactional DB — notification logs are write-heavy and can cause contention. The gotcha: log writes must be async and non-blocking. If logging fails, the notification should still be delivered.

Never Do This:
Don't log synchronously in the delivery path. If your log database is slow, it will delay delivery. Fire-and-forget the log write, or use a separate log queue.

Scaling: Handle Millions of Notifications

To scale, you need multiple workers consuming from the same queue. Use auto-scaling based on queue depth. Each worker should be stateless — no local state that ties it to a specific message. For ordering, use a FIFO queue with a message group ID (e.g., per user). For high throughput, use a standard queue (no ordering) and accept that messages may be delivered out of order. The trade-off: ordering vs. throughput.

Senior Shortcut:
Use SQS FIFO queues for per-user ordering. Set the MessageGroupId to the user_id. This ensures all notifications for a user are processed in order, while different users can be processed in parallel.

Idempotency: Stop Duplicate Notifications

Idempotency is the single most important feature. Without it, a retry sends the same notification twice. The fix: assign a unique idempotency key to each notification (e.g., UUID). Before sending, check if that key has already been processed. Store processed keys in a database with a TTL (e.g., 7 days). Use a unique constraint on the key to prevent race conditions. The classic mistake: checking idempotency in application code without a database constraint — two concurrent requests can both pass the check.

idempotency.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-- io.thecodeforge — System Design tutorial

-- Create table for idempotency keys
CREATE TABLE notification_idempotency (
    idempotency_key VARCHAR(255) PRIMARY KEY,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    status VARCHAR(20) NOT NULL DEFAULT 'pending'  -- pending, processing, completed
);

-- Use INSERT ... ON CONFLICT to atomically claim a key
INSERT INTO notification_idempotency (idempotency_key, status)
VALUES ('abc-123', 'processing')
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING status;
-- If the INSERT returned a row, we own this key. If NULL, it already exists.
Output
Returns 'processing' if key is new, NULL if duplicate.
The Classic Bug:
Error: Duplicate entry 'abc-123' for key 'PRIMARY' — This happens when two workers try to insert the same key concurrently. Use INSERT ... ON CONFLICT DO NOTHING (PostgreSQL) or INSERT IGNORE (MySQL) to handle it atomically.

Monitoring: Know When It Breaks

You need metrics: queue depth, delivery latency, success rate per channel, error rate per provider. Alert on queue depth > 10K (backlog growing) and success rate < 95%. Also monitor the dead-letter queue — if it's growing, something is systematically failing. The gotcha: don't alert on every single failure. Some failures are transient. Alert on sustained failure rates over a time window (e.g., 5 minutes).

Production Trap:
Alerting on every 4xx from an email provider will wake you up for nothing. Aggregate errors over 1 minute and alert if > 1% of requests fail.
● Production incidentPOST-MORTEMseverity: high

The 4GB Container That Kept Dying

Symptom
Notification delivery latency spiked from 2 seconds to 15 minutes, then the service crashed with OOMKilled.
Assumption
We assumed the queue was overloaded and added more consumers.
Root cause
The notification processing service loaded all pending notifications into memory before batching. With 500K pending messages, each ~8KB, that's 4GB — exceeding the container limit. The OOM killer terminated the pod.
Fix
Switched to streaming reads from the queue with a max batch size of 100. Added backpressure: if processing time exceeds 5 seconds, reduce batch size to 10.
Key lesson
  • Never load an entire queue into memory.
  • Always process in bounded batches with backpressure.
Production debug guideSystematic recovery paths for the failure modes engineers actually hit.3 entries
Symptom · 01
Notifications not being sent at all
Fix
1. Check queue depth (AWS SQS: aws sqs get-queue-attributes --queue-url <url> --attribute-names ApproximateNumberOfMessages). 2. Check worker logs for errors. 3. Verify worker is running and consuming messages. 4. Check DLQ for failed messages.
Symptom · 02
Duplicate notifications
Fix
1. Check idempotency key implementation. 2. Verify database unique constraint on idempotency_key. 3. Check if multiple workers are processing the same message (visibility timeout too short). 4. Increase visibility timeout to at least 6x processing time.
Symptom · 03
High latency (notifications delayed by hours)
Fix
1. Check queue depth. 2. Increase worker count (auto-scaling). 3. Check worker CPU/memory — maybe they're slow due to resource contention. 4. Check provider rate limits — you might be throttled.
★ Notification System Triage Cheat SheetFirst-response commands for when things go wrong — copy-paste ready.
Queue depth growing (check with `aws sqs get-queue-attributes --queue-url <url> --attribute-names ApproximateNumberOfMessages`)
Immediate action
Check if workers are alive and consuming.
Commands
kubectl get pods -l app=notification-worker
kubectl logs -l app=notification-worker --tail=50
Fix now
Scale up workers: kubectl scale deployment notification-worker --replicas=10
DLQ not empty (`aws sqs get-queue-attributes --queue-url <dlq-url> --attribute-names ApproximateNumberOfMessages`)+
Immediate action
Read one message to see the error.
Commands
aws sqs receive-message --queue-url <dlq-url> --max-number-of-messages=1
echo <message-body> | jq .
Fix now
Fix the root cause (e.g., invalid template, provider outage), then replay messages from DLQ.
High error rate from email provider (check logs for `429 Too Many Requests`)+
Immediate action
Check if you're rate-limited.
Commands
grep '429' /var/log/notification-worker.log | wc -l
Check provider dashboard for rate limit usage.
Fix now
Implement exponential backoff with jitter in delivery code. Reduce concurrency.
Worker OOMKilled (check `kubectl describe pod <pod-name>` for `OOMKilled`)+
Immediate action
Check memory usage before crash.
Commands
kubectl top pod <pod-name> --containers
Check if worker loads all messages into memory.
Fix now
Reduce batch size in worker. Set memory limits in deployment YAML.
FeatureFIFO QueueStandard Queue
OrderingGuaranteed (per group)Best-effort
ThroughputLimited (3,000 TPS with batching)Unlimited (virtually)
DeduplicationBuilt-in (content-based or ID)None (must implement yourself)
Use casePer-user notifications, financial transactionsBulk emails, analytics events
⚙ Quick Reference
4 commands from this guide
FileCommand / CodePurpose
ingestion_api.pyfrom flask import Flask, request, jsonifyIngestion API
worker.pyfrom time import sleepProcessing Worker
delivery.pydef send_email_with_retry(to, subject, body, max_retries=3):Delivery Layer
idempotency.sqlCREATE TABLE notification_idempotency (Idempotency

Key takeaways

1
Always decouple notification sending from your main app with a queue
never send synchronously.
2
Idempotency keys with database unique constraints are the only reliable way to prevent duplicate notifications.
3
Process messages in bounded batches with backpressure to avoid OOM crashes.
4
Monitor queue depth and DLQ size
they're the canary in the coal mine for notification system health.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
How does a notification system handle duplicate deliveries under concurr...
Q02SENIOR
When would you choose a FIFO queue over a standard queue for notificatio...
Q03SENIOR
What happens when a worker crashes mid-processing? How do you prevent me...
Q04JUNIOR
What is the purpose of a dead-letter queue in a notification system?
Q05SENIOR
You notice notification latency spikes every hour. What's your diagnosis...
Q06SENIOR
Design a notification system that can handle 10 million notifications pe...
Q01 of 06SENIOR

How does a notification system handle duplicate deliveries under concurrent load?

ANSWER
Use idempotency keys with a database unique constraint. Before processing, atomically insert the key with status 'processing'. If the insert fails (duplicate key), skip the message. This prevents two workers from sending the same notification.
FAQ · 4 QUESTIONS

Frequently Asked Questions

01
How do I design a notification system that handles millions of users?
02
What's the difference between push and pull notifications in system design?
03
How do I prevent duplicate notifications in a distributed system?
04
What happens when a notification provider is down? How do you handle it?
N
Naren Founder & Principal Engineer

20+ years shipping large-scale distributed systems. Notes here come from systems that actually shipped.

Follow
Verified
production tested
July 08, 2026
last updated
1,697
articles · all by Naren
🔥

That's Real World. Mark it forged?

3 min read · try the examples if you haven't

Previous
Design a Rate Limiter
12 / 40 · Real World
Next
Design a Search Autocomplete