Zum Inhalt springen

Ein Echtzeit-Benachrichtigungssystem von Grund auf bauen

Baue ein produktionsreifes Echtzeit-Benachrichtigungssystem mit SSE und WebSockets: Zustellgarantien, Reconnect, Persistierung und Lesestatus-Tracking.

5 Min. Lesezeit
Architekturdiagramm eines Benachrichtigungssystems mit Publisher, Message Queue, Zustelldienst und Client-Verbindungen über Server-Sent Events

Echtzeit-Benachrichtigungen wirken einfach, bis man sich mit Verbindungsabbrüchen, verlorenen Nachrichten, mehreren Tabs und der Frage auseinandersetzt, ob WebSockets oder Server-Sent Events die richtige Wahl sind. Die meisten Anwendungen brauchen keine bidirektionale Kommunikation — sie brauchen einen Server, der Updates zuverlässig an den Client pusht. Dieses Tutorial baut genau dieses System Schritt für Schritt.

Wir verwenden Server-Sent Events (SSE) für den Zustellkanal, weil SSE für die unidirektionale Server-zu-Client-Kommunikation einfacher ist als WebSockets, automatische Reconnects unterstützt und ohne spezielle Proxy-Konfiguration über HTTP/2 funktioniert.

Das Benachrichtigungs-Datenmodell

Bevor wir den Zustellmechanismus bauen, definieren wir, wie eine Benachrichtigung aussieht und wie der Lesestatus verfolgt wird.

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-Seite: SSE-Endpunkt

Der SSE-Endpunkt hält eine langlebige HTTP-Verbindung offen. Der Server schreibt Events in einem spezifischen Textformat, das die EventSource-API des Browsers nativ versteht.

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`);
}

Benachrichtigungen veröffentlichen

Wenn im System etwas passiert, das eine Benachrichtigung auslöst, wird sie an den verbundenen Nutzer veröffentlicht.

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-Seite: EventSource mit Reconnect

Die EventSource-API des Browsers übernimmt den Reconnect automatisch, aber wir müssen noch die Verwaltung des Benachrichtigungsstatus und die Koordination zwischen Tabs ergänzen.

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

Wir binden den Benachrichtigungs-Client über einen Hook in React-Komponenten ein, der den Lebenszyklus der Subscription verwaltet.

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>
  );
}

Die wichtigsten Erkenntnisse

Server-Sent Events sind die richtige Wahl für unidirektionale Server-zu-Client-Benachrichtigungen — sie sind einfacher als WebSockets, unterstützen automatische Browser-Reconnects mit last-event-id zur Wiederherstellung verpasster Nachrichten und funktionieren ohne spezielle Konfiguration nahtlos durch HTTP/2-Proxies. Persistiere Benachrichtigungen vor der Zustellung, damit Nutzer, die offline sind, verpasste Nachrichten beim Reconnect erhalten — der SSE-Header last-event-id in Kombination mit serverseitigem Speicher stellt sicher, dass bei Verbindungslücken keine Benachrichtigung verloren geht. Trenne die Erstellung der Benachrichtigung vom Zustellmechanismus: Der Notification-Service validiert die Einstellungen, persistiert die Benachrichtigung und versucht dann die Echtzeit-Zustellung — ist der Nutzer nicht verbunden, dient der persistente Speicher als Zustellgarantie. Die Koordination mehrerer Tabs erfordert, dass der Server Änderungen des Lesestatus an alle Verbindungen eines Nutzers broadcastet, sodass das Markieren einer Benachrichtigung als gelesen in einem Tab sofort in allen anderen offenen Tabs sichtbar wird — ganz ohne Polling.

Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX