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.

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.
// 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.
// ❌ 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// ✅ 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.
// 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.
// 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.
// 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.


