DevPrep
  • Interview Prep
  • Projects
  • Resources
  • Pricing
  • About Us
Submit Question
DevPrep
  • Pricing
  • About Us
Submit Question
  1. Home
  2. Articles
  3. Frontend Engineering
  4. System Design #10: Design a Notification System
XLinkedInReddit
MediumFrontend Engineering

System Design #10: Design a Notification System

D
DevPrep Team
7 min readยท0
Table of Contents
  • Hey folks, Rahul here ๐Ÿ‘‹
  • R โ€” Requirements
  • Functional Requirements
  • Non-Functional Requirements
  • A โ€” Architecture
  • Delivery Strategies
  • Component Architecture
  • Notification Flow
  • D โ€” Data Model
  • Notification Types and Grouping
  • Client State
  • Grouping Logic
  • I โ€” Interface Definition
  • SSE Connection
  • Mark as Read โ€” Optimistic with Batching
  • O โ€” Optimizations
  • 1. Cross-Tab Synchronization
  • 2. Unread Count Badge Animation
  • 3. Browser Push Notifications
  • 4. Notification Preferences
  • Production Gotchas Rahul Has Debugged ๐Ÿ”ฅ

Hey folks, Rahul here ๐Ÿ‘‹

Every app has notifications. Most implement them badly โ€” a polling loop that hammers the server, an unread count that's always wrong, and grouping logic that shows "John and 47 others liked your post" as 48 separate items. Sound familiar?

Let's design a notification system that handles real-time delivery, intelligent grouping, cross-tab synchronization, and the deceptively complex read/unread state machine.

R โ€” Requirements

Functional Requirements

  • Display in-app notification bell with unread count badge
  • Notification dropdown/panel with grouped notifications
  • Mark individual or all notifications as read
  • Real-time delivery โ€” new notifications appear without refresh
  • Push notifications (browser) with user opt-in
  • Notification preferences (per-category enable/disable)
  • Click-to-navigate: each notification links to relevant content

Non-Functional Requirements

  • Real-time: <2s delivery from event to UI
  • Consistency: Unread count must be accurate across tabs
  • Performance: Support users with 10K+ notifications
  • Battery: Mobile-friendly โ€” no aggressive polling
  • Graceful degradation: Works without WebSocket (falls back to polling)

A โ€” Architecture

Delivery Strategies

StrategyLatencyBatteryComplexityUse When
PollingHigh (interval)PoorLowFallback only
Long PollingMediumOKMediumSSE unavailable
SSE (Server-Sent Events)LowGoodMediumRead-only stream โœ…
WebSocketLowestGoodHighAlready have WS for other features

Recommendation: SSE for notifications specifically. It's simpler than WebSocket, works over HTTP/2, auto-reconnects, and notifications are inherently server โ†’ client (no bidirectional need).

Component Architecture

NotificationProvider (Context)
โ”œโ”€โ”€ NotificationBell         // Bell icon + unread badge
โ”œโ”€โ”€ NotificationPanel        // Dropdown/slideover
โ”‚   โ”œโ”€โ”€ NotificationFilters  // "All" | "Unread" | by category
โ”‚   โ”œโ”€โ”€ NotificationGroup    // "John and 3 others liked your post"
โ”‚   โ”‚   โ””โ”€โ”€ NotificationItem // Individual notification
โ”‚   โ””โ”€โ”€ LoadMoreTrigger      // Infinite scroll
โ”œโ”€โ”€ NotificationToast        // Ephemeral popup for new notifications
โ””โ”€โ”€ PushPermissionBanner     // "Enable push notifications?"

Notification Flow

Event (comment, like, etc.)
    โ†“
Backend creates notification row
    โ†“
Pushes via SSE/WebSocket to connected clients
    โ†“
NotificationProvider receives
    โ†“
โ”œโ”€โ”€ Update unread count (badge)
โ”œโ”€โ”€ Show toast (if panel is closed)
โ”œโ”€โ”€ Prepend to notification list (if panel is open)
โ””โ”€โ”€ Send browser push (if tab is background + user opted in)

D โ€” Data Model

Notification Types and Grouping

interface Notification {
  id: string;
  type: NotificationType;
  recipient_id: string;
  actor_id: string;         // Who triggered this
  actor: { name: string; avatar: string };
  target_type: 'post' | 'comment' | 'article';
  target_id: string;
  target_preview: string;   // "Your post about React hooks..."
  group_key: string;        // For grouping: "like:post:123"
  is_read: boolean;
  created_at: string;
  action_url: string;       // Where to navigate on click
}

type NotificationType = 
  | 'like'
  | 'comment'
  | 'reply'
  | 'mention'
  | 'follow'
  | 'system';

// Grouped notification for display
interface NotificationGroup {
  group_key: string;
  type: NotificationType;
  actors: { id: string; name: string; avatar: string }[];
  latest_at: string;
  count: number;            // Total in group
  is_read: boolean;         // All read?
  target_preview: string;
  action_url: string;
  // Display: "Alice, Bob, and 3 others liked your post"
}

Client State

interface NotificationState {
  groups: NotificationGroup[];     // Grouped for display
  unreadCount: number;             // Badge number
  isOpen: boolean;                 // Panel visibility
  filter: 'all' | 'unread';
  hasMore: boolean;                // Pagination
  cursor: string | null;
  isConnected: boolean;            // SSE status
  
  // Optimistic state
  pendingReadIds: Set<string>;     // Marked read locally, awaiting server
}

Grouping Logic

function groupNotifications(notifications: Notification[]): NotificationGroup[] {
  const groups = new Map<string, Notification[]>();
  
  for (const notif of notifications) {
    const existing = groups.get(notif.group_key) || [];
    existing.push(notif);
    groups.set(notif.group_key, existing);
  }
  
  return Array.from(groups.entries()).map(([key, notifs]) => {
    // Dedupe actors
    const actorMap = new Map<string, Notification['actor']>();
    notifs.forEach(n => actorMap.set(n.actor_id, n.actor));
    const actors = Array.from(actorMap.values());
    
    return {
      group_key: key,
      type: notifs[0].type,
      actors: actors.slice(0, 3),  // Show max 3 avatars
      count: actors.length,
      latest_at: notifs[0].created_at,
      is_read: notifs.every(n => n.is_read),
      target_preview: notifs[0].target_preview,
      action_url: notifs[0].action_url,
    };
  }).sort((a, b) => 
    new Date(b.latest_at).getTime() - new Date(a.latest_at).getTime()
  );
}

// Display text generator
function getGroupText(group: NotificationGroup): string {
  const { actors, count, type } = group;
  const verb = { like: 'liked', comment: 'commented on', reply: 'replied to', mention: 'mentioned you in', follow: 'followed you' }[type];
  
  if (count === 1) return `${actors[0].name} ${verb} ${group.target_preview}`;
  if (count === 2) return `${actors[0].name} and ${actors[1].name} ${verb} ${group.target_preview}`;
  return `${actors[0].name}, ${actors[1].name}, and ${count - 2} others ${verb} ${group.target_preview}`;
}

I โ€” Interface Definition

SSE Connection

function useNotificationStream(userId: string) {
  const [state, dispatch] = useReducer(notificationReducer, initialState);
  
  useEffect(() => {
    const eventSource = new EventSource(
      `/api/notifications/stream?userId=${userId}`,
      { withCredentials: true }
    );
    
    eventSource.addEventListener('notification', (e) => {
      const notification: Notification = JSON.parse(e.data);
      dispatch({ type: 'NEW_NOTIFICATION', notification });
      
      // Show toast if panel is closed
      if (!state.isOpen) {
        showNotificationToast(notification);
      }
    });
    
    eventSource.addEventListener('count_sync', (e) => {
      // Periodic sync to correct any drift
      const { unreadCount } = JSON.parse(e.data);
      dispatch({ type: 'SYNC_COUNT', unreadCount });
    });
    
    eventSource.onerror = () => {
      dispatch({ type: 'SET_CONNECTED', connected: false });
      // EventSource auto-reconnects โ€” no manual retry needed!
    };
    
    eventSource.onopen = () => {
      dispatch({ type: 'SET_CONNECTED', connected: true });
    };
    
    return () => eventSource.close();
  }, [userId]);
  
  return state;
}

Mark as Read โ€” Optimistic with Batching

// Batch mark-as-read calls to avoid N API calls
class ReadBatcher {
  private queue: string[] = [];
  private timer: ReturnType<typeof setTimeout> | null = null;
  
  markRead(notificationId: string) {
    this.queue.push(notificationId);
    
    if (!this.timer) {
      this.timer = setTimeout(() => {
        this.flush();
      }, 500); // Batch within 500ms window
    }
  }
  
  markAllRead() {
    this.queue = [];
    if (this.timer) clearTimeout(this.timer);
    this.timer = null;
    
    return api.post('/notifications/mark-all-read');
  }
  
  private async flush() {
    const ids = [...this.queue];
    this.queue = [];
    this.timer = null;
    
    await api.post('/notifications/mark-read', { ids });
  }
}

// Auto-mark as read when notification is visible for 1.5s
function useAutoMarkRead(notificationId: string, isVisible: boolean) {
  useEffect(() => {
    if (!isVisible) return;
    
    const timer = setTimeout(() => {
      readBatcher.markRead(notificationId);
    }, 1500);
    
    return () => clearTimeout(timer);
  }, [notificationId, isVisible]);
}

O โ€” Optimizations

1. Cross-Tab Synchronization

// Use BroadcastChannel to sync state across tabs
const channel = new BroadcastChannel('notifications');

// When one tab marks notifications as read:
function markAsRead(ids: string[]) {
  dispatch({ type: 'MARK_READ', ids });
  channel.postMessage({ type: 'MARK_READ', ids });
  api.post('/notifications/mark-read', { ids });
}

// Other tabs receive:
channel.onmessage = (event) => {
  dispatch(event.data);
};

// Only one tab should maintain the SSE connection
// Use a "leader election" pattern
const leader = await navigator.locks.request(
  'notification-stream',
  { ifAvailable: true },
  (lock) => {
    if (lock) {
      // This tab is the leader โ€” connect SSE
      connectSSE();
      return new Promise(() => {}); // Hold the lock
    }
    return false; // Another tab is leader
  }
);

if (!leader) {
  // This tab receives updates via BroadcastChannel from the leader tab
}

2. Unread Count Badge Animation

function NotificationBadge({ count }: { count: number }) {
  const prevCount = useRef(count);
  const [animate, setAnimate] = useState(false);
  
  useEffect(() => {
    if (count > prevCount.current) {
      setAnimate(true);
      const timer = setTimeout(() => setAnimate(false), 300);
      prevCount.current = count;
      return () => clearTimeout(timer);
    }
    prevCount.current = count;
  }, [count]);
  
  if (count === 0) return null;
  
  return (
    <span className={cn(
      "absolute -top-1 -right-1 flex items-center justify-center",
      "min-w-[18px] h-[18px] rounded-full bg-destructive text-destructive-foreground",
      "text-[10px] font-bold px-1",
      animate && "animate-bounce"
    )}>
      {count > 99 ? '99+' : count}
    </span>
  );
}

3. Browser Push Notifications

async function requestPushPermission(): Promise<boolean> {
  if (!("Notification" in window)) return false;
  
  const permission = await Notification.requestPermission();
  if (permission !== 'granted') return false;
  
  // Register service worker for background push
  const registration = await navigator.serviceWorker.register('/sw.js');
  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: VAPID_PUBLIC_KEY,
  });
  
  // Send subscription to server
  await api.post('/push/subscribe', subscription.toJSON());
  return true;
}

// Service worker handler (sw.js)
self.addEventListener('push', (event) => {
  const data = event.data?.json();
  event.waitUntil(
    self.registration.showNotification(data.title, {
      body: data.body,
      icon: data.icon,
      badge: '/notification-badge.png',
      data: { url: data.action_url },
      tag: data.group_key, // Replaces previous notification with same tag
    })
  );
});

self.addEventListener('notificationclick', (event) => {
  event.notification.close();
  event.waitUntil(clients.openWindow(event.notification.data.url));
});

4. Notification Preferences

interface NotificationPreferences {
  channels: {
    in_app: boolean;   // Always true
    push: boolean;
    email: boolean;
  };
  categories: {
    likes: { in_app: boolean; push: boolean; email: boolean };
    comments: { in_app: boolean; push: boolean; email: boolean };
    mentions: { in_app: boolean; push: boolean; email: boolean };
    follows: { in_app: boolean; push: boolean; email: boolean };
    system: { in_app: boolean; push: boolean; email: boolean }; // Can't disable
  };
  quiet_hours: {
    enabled: boolean;
    start: string; // "22:00"
    end: string;   // "07:00"
    timezone: string;
  };
}

Production Gotchas Rahul Has Debugged ๐Ÿ”ฅ

  1. Unread Count Drift: If a notification is created between the initial fetch and SSE connection, you miss it. Solution: SSE connection sends a count_sync event every 60s to reconcile.
  2. Self-Notifications: Don't notify users about their own actions. The backend must filter actor_id !== recipient_id โ€” never rely on the frontend for this.
  3. Notification Storms: A viral post generates thousands of likes. Without grouping, the user gets 1,000 notifications. Backend should coalesce within a time window (e.g., group all likes within 5 minutes into one notification).
  4. SSE Memory Leak: EventSource accumulates all messages in memory if you don't handle them. Always consume events promptly. Some browsers also leak if you create/destroy EventSource instances rapidly.
  5. Mobile Tab Backgrounding: iOS Safari kills SSE connections when the tab is backgrounded. On visibilitychange โ†’ visible, reconnect and fetch missed notifications since lastEventId.

Next up: #11: Design a Drag-and-Drop Kanban Board โ€” reorder algorithms, optimistic multi-list moves, and the fractional indexing trick. ๐Ÿ“‹

Related Articles

MediumFrontend Engineering

System Design #12: Design a Multi-Step Form Wizard

7 min read
MediumFrontend Engineering

Mastering Senior-Level JavaScript Interview Concepts

2 min read
MediumFrontend Engineering

System Design #9: Design a Collaborative Text Editor

9 min read

Comments (0)

Sign in to leave a comment.

No comments yet. Be the first to comment.

Table of Contents

  • Hey folks, Rahul here ๐Ÿ‘‹
  • R โ€” Requirements
  • Functional Requirements
  • Non-Functional Requirements
  • A โ€” Architecture
  • Delivery Strategies
  • Component Architecture
  • Notification Flow
  • D โ€” Data Model
  • Notification Types and Grouping
  • Client State
  • Grouping Logic
  • I โ€” Interface Definition
  • SSE Connection
  • Mark as Read โ€” Optimistic with Batching
  • O โ€” Optimizations
  • 1. Cross-Tab Synchronization
  • 2. Unread Count Badge Animation
  • 3. Browser Push Notifications
  • 4. Notification Preferences
  • Production Gotchas Rahul Has Debugged ๐Ÿ”ฅ

Series

View all Frontend Engineering articles โ†’

Practice

  • JavaScript
  • DSA
  • Machine Coding
  • System Design

Resources

  • Learning Tracks
  • Articles
  • Roadmaps
  • Compare Concepts
  • Glossary
  • Developer Tools
  • All Questions

Company

  • About
  • Pricing

Legal

  • Privacy Policy
  • Terms of Service
DevPrep

ยฉ 2026 DevPrep. All rights reserved.