Design a Notification System

Difficulty: Advanced

Question

Design a notification system that supports push notifications, email, SMS, and in-app notifications for millions of users.

Answer

A notification system must handle multiple channels (push, email, SMS, in-app), respect user preferences, support prioritization, and scale to millions of notifications per day.

Core components: 1. Notification Service: Accepts notification requests, validates, and routes 2. User Preference Store: Per-user channel preferences and quiet hours 3. Priority Queue: Urgent (OTP) vs normal (marketing) notifications 4. Channel Adapters: Push (FCM/APNs), Email (SES), SMS (Twilio), In-app (WebSocket) 5. Template Engine: Reusable notification templates 6. Analytics: Delivery rates, open rates, click-through rates

Key challenges: deduplication, rate limiting per user, retry with backoff, template versioning.

Code examples

Notification System Architecture

// Notification service flow
// 1. Event occurs -> 2. Build notification -> 3. Route to channels

class NotificationService {
  async send(event) {
    // 1. Get user preferences
    const prefs = await userPrefsStore.get(event.userId);

    // 2. Check if user wants this type
    if (!prefs.enabled[event.type]) return;

    // 3. Check quiet hours
    if (this.isQuietHours(prefs)) {
      await delayQueue.schedule(event, prefs.quietEnd);
      return;
    }

    // 4. Render template
    const content = await templateEngine.render(
      event.template,
      event.data
    );

    // 5. Route to enabled channels
    const channels = prefs.channels[event.type] || ['in_app'];

    for (const channel of channels) {
      await messageQueue.publish(`notifications.${channel}`, {
        userId: event.userId,
        content,
        priority: event.priority || 'normal',
        dedupeKey: `${event.type}:${event.userId}:${event.entityId}`
      });
    }
  }
}

// Priority levels
// CRITICAL: OTP, security alerts (immediate, all channels)
// HIGH: order updates, payment confirmations
// NORMAL: social interactions, reminders
// LOW: marketing, recommendations (batch-friendly)

The notification service decouples event handling from delivery. User preferences control which channels receive which notification types. Priority levels ensure critical notifications are never delayed.

Channel Adapters & Retry Logic

// Channel adapter interface
class PushAdapter {
  async send(notification) {
    try {
      if (notification.platform === 'ios') {
        await apns.send(notification.deviceToken, {
          alert: { title: notification.title, body: notification.body },
          badge: notification.unreadCount
        });
      } else {
        await fcm.send({
          token: notification.deviceToken,
          notification: { title: notification.title, body: notification.body }
        });
      }
      return { success: true };
    } catch (error) {
      if (error.code === 'INVALID_TOKEN') {
        await deviceStore.removeToken(notification.deviceToken);
        return { success: false, retry: false };
      }
      return { success: false, retry: true };
    }
  }
}

// Retry with exponential backoff
class RetryHandler {
  async processWithRetry(notification, adapter, maxRetries = 3) {
    for (let attempt = 0; attempt <= maxRetries; attempt++) {
      const result = await adapter.send(notification);

      if (result.success) return result;
      if (!result.retry) return result;

      const delay = Math.pow(2, attempt) * 1000; // 1s, 2s, 4s
      await sleep(delay);
    }

    // Move to dead letter queue after all retries fail
    await deadLetterQueue.push(notification);
  }
}

Each channel has its own adapter with specific error handling. Invalid device tokens are cleaned up immediately. Exponential backoff prevents overwhelming external services during outages.

In-App Notification Storage

-- In-app notification storage
CREATE TABLE notifications (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL,
  type VARCHAR(50) NOT NULL,
  title VARCHAR(255) NOT NULL,
  body TEXT,
  data JSONB,
  read BOOLEAN DEFAULT false,
  created_at TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_notif_user_unread
  ON notifications(user_id, created_at DESC)
  WHERE read = false;

-- API endpoints
// GET  /api/notifications?unread=true&limit=20
// POST /api/notifications/:id/read
// POST /api/notifications/read-all

// Real-time delivery via WebSocket
// When notification is created, also push
// to the user's WebSocket connection if online

// Batch digest for low-priority notifications
// Instead of: "Alice liked your post" x 50
// Send: "Alice and 49 others liked your post"

Partial index on unread notifications makes fetching the unread count very fast. Low-priority notifications are batched into digest summaries to avoid notification fatigue.

Key points

Concepts covered

Push Notifications, Event-Driven, Message Queue, Priority Queue, Fan-out