Saltar al contenido

Cómo construir un sistema de notificaciones en tiempo real desde cero

Construye un sistema de notificaciones en tiempo real con Server-Sent Events y WebSockets: garantías de entrega, reconexión, persistencia y estado de lectura.

5 min de lectura
Diagrama de arquitectura que muestra un sistema de notificaciones con un publicador, una cola de mensajes, un servicio de entrega y conexiones de cliente que usan Server-Sent Events

Las notificaciones en tiempo real parecen sencillas hasta que te toca lidiar con caídas de conexión, mensajes perdidos, múltiples pestañas y la pregunta de si usar WebSockets o Server-Sent Events. La mayoría de las aplicaciones no necesitan comunicación bidireccional: necesitan que el servidor envíe actualizaciones al cliente de forma fiable. Este tutorial construye ese sistema paso a paso.

Usaremos Server-Sent Events (SSE) para el canal de entrega porque es más simple que WebSockets para la comunicación unidireccional de servidor a cliente, soporta reconexión automática y funciona sobre HTTP/2 sin configuración especial del proxy.

El modelo de datos de las notificaciones

Antes de construir el mecanismo de entrega, define cómo se ve una notificación y cómo se registra su estado de lectura.

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

Lado del servidor: endpoint SSE

El endpoint SSE mantiene una conexión HTTP de larga duración. El servidor escribe eventos en un formato de texto específico que la API EventSource del navegador entiende de forma nativa.

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

Publicar notificaciones

Cuando algo ocurre en el sistema que dispara una notificación, publícala al usuario conectado.

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

Lado del cliente: EventSource con reconexión

La API EventSource del navegador maneja la reconexión automáticamente, pero necesitamos añadir gestión del estado de las notificaciones y coordinación entre pestañas.

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

Integración con React

Conecta el cliente de notificaciones con los componentes de React mediante un hook que gestiona el ciclo de vida de la suscripción.

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

Conclusiones clave

Server-Sent Events son la elección correcta para notificaciones unidireccionales de servidor a cliente: son más simples que WebSockets, soportan la reconexión automática del navegador con last-event-id para recuperar mensajes perdidos y funcionan sin problemas a través de proxies HTTP/2 sin configuración especial. Persiste las notificaciones antes de entregarlas para que los usuarios desconectados reciban los mensajes perdidos al reconectarse: el encabezado last-event-id de SSE combinado con el almacenamiento del lado del servidor garantiza que ninguna notificación se pierda durante las interrupciones de la conexión. Separa la creación de la notificación del mecanismo de entrega: el servicio de notificaciones valida las preferencias, persiste la notificación y luego intenta la entrega en tiempo real; si el usuario no está conectado, el almacenamiento persistente actúa como garantía de entrega. La coordinación entre pestañas requiere que el servidor difunda los cambios de estado de lectura a todas las conexiones de un usuario, asegurando que marcar una notificación como leída en una pestaña se refleje inmediatamente en todas las demás pestañas abiertas sin polling.

Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX