DEV Community

Cover image for HLD: Notification System
Gouranga Das Samrat
Gouranga Das Samrat

Posted on

HLD: Notification System

Design a notification system that sends push notifications, emails, and SMS to users based on events in the platform.


1️⃣ Clarify Requirements

Functional Requirements

  • Send notifications via: Push (iOS/Android), Email, SMS
  • Trigger types: real-time (someone liked your post) and scheduled (weekly digest)
  • Users can set preferences: opt-in/out per channel and notification type
  • Support for templated notifications (not hardcoded)
  • Delivery guarantee — important notifications must not be dropped

Non-Functional Requirements

  • High throughput — 10M notifications/day
  • Low latency for real-time notifications (< 5 seconds)
  • Reliability — retry on failure
  • Scalability — handle spikes (breaking news, product launches)
  • Observability — track delivered/failed/opened

2️⃣ Estimate Scale

Notifications/day: 10M
Notifications/sec: 10M / 86,400 ≈ 116/sec (avg)
Peak: 1,000/sec (event spikes)

Breakdown:
  Push: 60% → 6M/day
  Email: 30% → 3M/day
  SMS: 10% → 1M/day

Storage (notification log):
  1 record ≈ 500 bytes
  10M/day × 500B = 5 GB/day
  Retention: 90 days → 450 GB
Enter fullscreen mode Exit fullscreen mode

3️⃣ API Design

# Send a notification (internal service call)
POST /notifications/send
Body: {
  "userId": "user_123",
  "type": "like_received",
  "channels": ["push", "email"],  // or leave empty  use user preferences
  "data": {
    "actorName": "Rahul",
    "postTitle": "My Design Notes"
  }
}

# User preference management
GET  /users/:userId/notification-preferences
PUT  /users/:userId/notification-preferences
Body: {
  "push": { "like_received": true, "new_follower": true, "marketing": false },
  "email": { "weekly_digest": true, "marketing": false }
}

# Scheduled notifications
POST /notifications/schedule
Body: {
  "templateId": "weekly_digest",
  "scheduledAt": "2024-01-15T09:00:00Z",
  "userSegment": "active_users"
}
Enter fullscreen mode Exit fullscreen mode

4️⃣ High-Level Architecture

[Event Sources]
  - User Service ("user A liked post")
  - Order Service ("your order shipped")
  - Scheduler ("send weekly digest")
        │
        ▼
[Notification Service API]
        │
        ├─► Check User Preferences → skip if opted out
        ├─► Build notification from template
        ├─► Route to appropriate channels
        │
        ▼
[Message Queue (Kafka / SQS)]
  ├──► [Push Queue]
  ├──► [Email Queue]
  └──► [SMS Queue]
        │
        ▼
[Channel Workers]
  ├──► [Push Worker] → FCM (Android) / APNs (iOS)
  ├──► [Email Worker] → SendGrid / SES / Mailgun
  └──► [SMS Worker] → Twilio / SNS
        │
        ▼
[Delivery Log DB] ← track status per notification
Enter fullscreen mode Exit fullscreen mode

5️⃣ Component Deep Dives

Preference Service

# User preferences (stored in DB, cached in Redis)
preferences = {
  "push": {
    "like_received": True,
    "new_follower": True,
    "marketing": False
  },
  "email": {
    "weekly_digest": True,
    "security_alert": True,
    "marketing": False
  },
  "sms": {
    "security_alert": True,
    "marketing": False
  }
}

def should_notify(userId, notifType, channel):
    prefs = redis.get(f"prefs:{userId}") or db.get(userId)
    return prefs.get(channel, {}).get(notifType, True)  # default: on
Enter fullscreen mode Exit fullscreen mode

Template Service

// Template: "like_received"
{
  "templateId": "like_received",
  "push": {
    "title": "{{actorName}} liked your post",
    "body": "\"{{postTitle}}\" is getting attention!"
  },
  "email": {
    "subject": "{{actorName}} liked your post",
    "templateFile": "like-received.html"
  }
}
Enter fullscreen mode Exit fullscreen mode

Render: inject actual values, produce final notification content.

Push Notification Worker

def send_push(notification):
    device_tokens = db.get_device_tokens(notification.userId)

    for token in device_tokens:
        if token.platform == "android":
            fcm.send({
                "token": token.value,
                "title": notification.title,
                "body": notification.body,
                "data": notification.data
            })
        elif token.platform == "ios":
            apns.send({
                "deviceToken": token.value,
                "aps": { "alert": { "title": notification.title, "body": notification.body } }
            })
Enter fullscreen mode Exit fullscreen mode

Retry & Dead Letter

Worker fails to send → retry with exponential backoff
  Attempt 1: immediate
  Attempt 2: 30 seconds
  Attempt 3: 5 minutes
  Attempt 4: 30 minutes
  Attempt 5: DLQ (manual inspection or discard)
Enter fullscreen mode Exit fullscreen mode

6️⃣ Database Schema

-- Notification log (Cassandra — write-heavy, time-series)
CREATE TABLE notifications (
  notification_id  UUID,
  user_id          BIGINT,
  type             VARCHAR(50),
  channel          VARCHAR(20),  -- push/email/sms
  status           VARCHAR(20),  -- pending/sent/failed/delivered/opened
  payload          JSONB,
  created_at       TIMESTAMP,
  sent_at          TIMESTAMP,
  PRIMARY KEY (user_id, created_at, notification_id)
) WITH CLUSTERING ORDER BY (created_at DESC);

-- User device tokens
CREATE TABLE device_tokens (
  user_id    BIGINT,
  token      TEXT,
  platform   VARCHAR(10),  -- ios/android/web
  created_at TIMESTAMP,
  PRIMARY KEY (user_id, token)
);

-- User preferences (PostgreSQL, read-heavy)
CREATE TABLE notification_preferences (
  user_id     BIGINT PRIMARY KEY,
  preferences JSONB,
  updated_at  TIMESTAMP
);
Enter fullscreen mode Exit fullscreen mode

7️⃣ Handling Scale Spikes

Event: Product Launch → 10M users notified

Problem: Sudden flood of 10M notifications in seconds.

Solution: Rate limiting the outbound queue

Email: SendGrid allows 1000 emails/sec
→ Queue backs up → workers drain at 1000/sec → all 10M sent in ~3 hours
→ Users get it "within a few hours" → acceptable for marketing
Enter fullscreen mode Exit fullscreen mode

Priority Queues

HIGH priority queue:   security alerts, OTPs → process immediately
MEDIUM priority queue: social notifications → within seconds
LOW priority queue:    marketing, digests → within hours
Enter fullscreen mode Exit fullscreen mode

Workers check HIGH first, then MEDIUM, then LOW.


8️⃣ Observability

Track every notification:

Events to log:
  - notification_queued     (timestamp, userId, type, channel)
  - notification_sent       (timestamp, provider response)
  - notification_delivered  (timestamp, from FCM/APNs delivery receipt)
  - notification_opened     (timestamp, from SDK tracking)
  - notification_failed     (timestamp, error, retryCount)

Metrics:
  - Delivery rate per channel
  - Average send latency (queued → sent)
  - Failure rate per provider
  - Open rate per notification type
Enter fullscreen mode Exit fullscreen mode

🎨 Diagram

The diagram shows:

  • Event sources → Notification Service → Kafka queues → channel workers → external providers
  • Preference check before queuing
  • Template rendering step
  • Retry with DLQ
  • Priority queue lanes (high/medium/low)
  • Delivery status tracking

✅ Trade-offs Summary

Decision Choice Rationale
Queue type Kafka/SQS Decouple events from delivery, absorb spikes
Priority Multiple queues Don't let marketing delay OTPs
Retry Exponential backoff Avoid hammering failed providers
Preference storage Redis cache + DB Fast reads on every notification
Delivery tracking Cassandra High-volume, time-series write pattern

Top comments (0)