Skip to content

Building a Real-Time Notification System from Scratch

Build a production-ready real-time notification system with Server-Sent Events and WebSockets: delivery guarantees, reconnection, persistence, read state.

4 min read
Architecture diagram showing a notification system with a publisher, message queue, delivery service, and client connections using Server-Sent Events

Real-time notifications seem simple until you deal with connection drops, missed messages, multiple tabs, and the question of whether to use WebSockets or Server-Sent Events. Most applications don't need bidirectional communication—they need the server to push updates to the client reliably. This tutorial builds that system step by step.

We'll use Server-Sent Events (SSE) for the delivery channel because it's simpler than WebSockets for one-way server-to-client communication, supports automatic reconnection, and works through HTTP/2 without special proxy configuration.

The Notification Data Model

Before building the delivery mechanism, define what a notification looks like and how read-state is tracked.

tstypescript
// types.ts
interface Notification {
  id: string;
  userId: string;
  type: NotificationType;
  title: string;
  body: string;
  data?: Record<string, unknown>;
  read: boolean;
  createdAt: Date;
  expiresAt?: Date;
}
 
type NotificationType =
  | "info"
  | "success"
  | "warning"
  | "error"
  | "mention"
  | "assignment"
  | "comment";
 
interface NotificationPreferences {
  userId: string;
  enabledTypes: NotificationType[];
  muteUntil?: Date;
}

Server-Side: SSE Endpoint

The SSE endpoint maintains a long-lived HTTP connection. The server writes events in a specific text format that the browser's EventSource API understands natively.

tstypescript
// ❌ Polling — wasteful, high latency
app.get("/api/notifications", async (req, res) => {
  const notifications = await db.getUnread(req.user.id);
  res.json(notifications);
});
// Client polls every 5 seconds — 17,280 requests/day
// per user, most returning no new data
tstypescript
// ✅ Server-Sent Events — efficient, real-time
import { Router, Request, Response } from "express";
 
// In-memory connection registry
// (use Redis pub/sub for multi-instance)
const connections = new Map<string, Set<Response>>();
 
const router = Router();
 
router.get(
  "/api/notifications/stream",
  (req: Request, res: Response) => {
    const userId = req.user!.id;
 
    // SSE headers
    res.writeHead(200, {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache",
      Connection: "keep-alive",
      "X-Accel-Buffering": "no", // Disable nginx buffering
    });
 
    // Send initial connection event
    res.write(
      `event: connected\ndata: ${JSON.stringify({
        userId,
        timestamp: new Date().toISOString(),
      })}\n\n`
    );
 
    // Register this connection
    if (!connections.has(userId)) {
      connections.set(userId, new Set());
    }
    connections.get(userId)!.add(res);
 
    // Send missed notifications since last connection
    const lastEventId = req.headers["last-event-id"];
    if (lastEventId) {
      sendMissedNotifications(userId, lastEventId, res);
    }
 
    // Heartbeat to detect dead connections
    const heartbeat = setInterval(() => {
      res.write(": heartbeat\n\n");
    }, 30_000);
 
    // Cleanup on disconnect
    req.on("close", () => {
      clearInterval(heartbeat);
      connections.get(userId)?.delete(res);
      if (connections.get(userId)?.size === 0) {
        connections.delete(userId);
      }
    });
  }
);
 
async function sendMissedNotifications(
  userId: string,
  lastEventId: string,
  res: Response
) {
  const missed = await db.getNotificationsAfter(
    userId,
    lastEventId
  );
  for (const notification of missed) {
    sendSSE(res, "notification", notification);
  }
}
 
function sendSSE(
  res: Response,
  event: string,
  data: unknown
) {
  const id =
    typeof data === "object" && data !== null && "id" in data
      ? (data as { id: string }).id
      : Date.now().toString();
 
  res.write(`id: ${id}\n`);
  res.write(`event: ${event}\n`);
  res.write(`data: ${JSON.stringify(data)}\n\n`);
}

Publishing Notifications

When something happens in the system that triggers a notification, publish it to the connected user.

tstypescript
// notification-service.ts
import { randomUUID } from "node:crypto";
 
class NotificationService {
  async send(
    userId: string,
    type: NotificationType,
    title: string,
    body: string,
    data?: Record<string, unknown>
  ): Promise<Notification> {
    // Check user preferences
    const prefs = await db.getPreferences(userId);
    if (prefs?.muteUntil && prefs.muteUntil > new Date()) {
      throw new Error("User notifications are muted");
    }
    if (prefs && !prefs.enabledTypes.includes(type)) {
      throw new Error(
        `User has disabled ${type} notifications`
      );
    }
 
    // Persist notification
    const notification: Notification = {
      id: randomUUID(),
      userId,
      type,
      title,
      body,
      data,
      read: false,
      createdAt: new Date(),
    };
 
    await db.saveNotification(notification);
 
    // Deliver to connected clients
    this.deliverToUser(userId, notification);
 
    return notification;
  }
 
  private deliverToUser(
    userId: string,
    notification: Notification
  ) {
    const userConnections = connections.get(userId);
    if (!userConnections) return; // User not connected
 
    for (const res of userConnections) {
      try {
        sendSSE(res, "notification", notification);
      } catch {
        // Connection dead — cleanup will handle it
        userConnections.delete(res);
      }
    }
  }
 
  async markAsRead(
    userId: string,
    notificationId: string
  ): Promise<void> {
    await db.markRead(userId, notificationId);
 
    // Notify other tabs that this notification was read
    this.deliverToUser(userId, {
      id: notificationId,
      type: "read-sync" as NotificationType,
      userId,
      title: "",
      body: "",
      read: true,
      createdAt: new Date(),
    });
  }
 
  async markAllAsRead(userId: string): Promise<void> {
    await db.markAllRead(userId);
 
    this.deliverToUser(userId, {
      id: randomUUID(),
      type: "read-all-sync" as NotificationType,
      userId,
      title: "",
      body: "",
      read: true,
      createdAt: new Date(),
    });
  }
}
 
export const notificationService =
  new NotificationService();

Client-Side: EventSource with Reconnection

The browser's EventSource API handles reconnection automatically, but we need to add notification state management and multi-tab coordination.

tstypescript
// notification-client.ts
class NotificationClient {
  private eventSource: EventSource | null = null;
  private listeners = new Set<
    (notification: Notification) => void
  >();
  private unreadCount = 0;
 
  connect() {
    if (this.eventSource) return;
 
    this.eventSource = new EventSource(
      "/api/notifications/stream",
      { withCredentials: true }
    );
 
    this.eventSource.addEventListener(
      "connected",
      (event) => {
        console.log(
          "Notification stream connected",
          JSON.parse(event.data)
        );
      }
    );
 
    this.eventSource.addEventListener(
      "notification",
      (event) => {
        const notification: Notification = JSON.parse(
          event.data
        );
        this.handleNotification(notification);
      }
    );
 
    this.eventSource.onerror = () => {
      console.warn(
        "SSE connection lost — reconnecting..."
      );
      // EventSource reconnects automatically
      // last-event-id header sent on reconnect
      // ensures no missed notifications
    };
  }
 
  private handleNotification(
    notification: Notification
  ) {
    if (!notification.read) {
      this.unreadCount++;
      this.updateBadge();
    }
 
    // Show browser notification if permitted
    if (
      Notification.permission === "granted" &&
      document.hidden
    ) {
      new Notification(notification.title, {
        body: notification.body,
        tag: notification.id,
      });
    }
 
    // Notify all registered listeners
    for (const listener of this.listeners) {
      listener(notification);
    }
  }
 
  private updateBadge() {
    // Update favicon badge or tab title
    document.title = this.unreadCount > 0
      ? `(${this.unreadCount}) My App`
      : "My App";
  }
 
  onNotification(
    callback: (notification: Notification) => void
  ): () => void {
    this.listeners.add(callback);
    return () => this.listeners.delete(callback);
  }
 
  disconnect() {
    this.eventSource?.close();
    this.eventSource = null;
  }
}
 
export const notificationClient =
  new NotificationClient();

React Integration

Wire the notification client into React components with a hook that manages the subscription lifecycle.

tsxtsx
// useNotifications.ts
import { useState, useEffect, useCallback } from "react";
import { notificationClient } from "./notification-client";
 
export function useNotifications() {
  const [notifications, setNotifications] = useState<
    Notification[]
  >([]);
 
  useEffect(() => {
    notificationClient.connect();
 
    const unsubscribe =
      notificationClient.onNotification(
        (notification) => {
          setNotifications((prev) => [
            notification,
            ...prev,
          ]);
        }
      );
 
    // Load initial notifications
    fetch("/api/notifications?limit=20")
      .then((res) => res.json())
      .then(setNotifications);
 
    return () => {
      unsubscribe();
    };
  }, []);
 
  const markAsRead = useCallback(
    async (id: string) => {
      await fetch(`/api/notifications/${id}/read`, {
        method: "POST",
      });
      setNotifications((prev) =>
        prev.map((n) =>
          n.id === id ? { ...n, read: true } : n
        )
      );
    },
    []
  );
 
  const unreadCount = notifications.filter(
    (n) => !n.read
  ).length;
 
  return { notifications, unreadCount, markAsRead };
}
 
// NotificationBell.tsx
export function NotificationBell() {
  const { notifications, unreadCount, markAsRead } =
    useNotifications();
  const [open, setOpen] = useState(false);
 
  return (
    <div className="relative">
      <button onClick={() => setOpen(!open)}>
        🔔
        {unreadCount > 0 && (
          <span className="badge">{unreadCount}</span>
        )}
      </button>
 
      {open && (
        <div className="notification-panel">
          {notifications.map((n) => (
            <div
              key={n.id}
              className={n.read ? "read" : "unread"}
              onClick={() => markAsRead(n.id)}
            >
              <strong>{n.title}</strong>
              <p>{n.body}</p>
              <time>
                {new Date(n.createdAt).toLocaleString()}
              </time>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

Key Takeaways

Server-Sent Events are the right choice for one-way server-to-client notifications—they're simpler than WebSockets, support automatic browser reconnection with last-event-id for missed message recovery, and work seamlessly through HTTP/2 proxies without special configuration. Persist notifications before delivering them so that users who are offline receive missed messages when they reconnect—the SSE last-event-id header combined with server-side storage ensures no notification is lost during connection gaps. Separate the notification creation from the delivery mechanism: the notification service validates preferences, persists the notification, and then attempts real-time delivery—if the user isn't connected, the persistent store serves as the delivery guarantee. Multi-tab coordination requires the server to broadcast read-state changes to all of a user's connections, ensuring that marking a notification as read in one tab reflects immediately in all other open tabs without polling.

Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX