WebSockets na Prática: Sistema de Notificações em Tempo Real com Node.js e React

Conteúdo técnico toda semana
Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.
Sem spam. Cancele a qualquer momento com 1 clique.
Neste artigo
O problema que polling não resolve
Uma requisição HTTP a cada 5 segundos para checar notificações novas. Multiplique por 10 mil usuários conectados. São 2 mil requests por segundo no seu servidor, e a maioria retorna 304 ou um array vazio. O polling funciona, mas o custo é desproporcional ao valor entregue.
WebSocket resolve isso invertendo a direção: o servidor empurra dados para o cliente quando há algo novo. Uma conexão TCP persistente, bidirecional, com overhead de framing mínimo (2 a 14 bytes por mensagem, contra centenas de bytes de headers HTTP por request de polling).
Este post implementa um sistema de notificações completo: servidor WebSocket com autenticação no handshake, broadcast por canal de usuário, reconexão automática no React e tratamento dos erros que aparecem quando a conexão cai no meio de uma mensagem.
Escolhendo a biblioteca: ws vs Socket.IO vs uWebSockets.js
Antes de escrever código, a decisão de biblioteca importa. Cada uma carrega trade-offs reais.
| Critério | ws | Socket.IO | uWebSockets.js |
|---|---|---|---|
| Protocolo | WebSocket puro (RFC 6455) | Protocolo próprio sobre WebSocket/polling | WebSocket puro |
| Overhead de payload | Nenhum | ~50-100 bytes por mensagem (envelope de evento) | Nenhum |
| Fallback HTTP long-polling | Não | Sim, automático | Não |
| Reconexão automática (client) | Manual | Embutida | Manual |
| Performance (mensagens/s em single thread) | ~40k | ~15k | ~100k+ |
| Compatibilidade com proxy/CDN | Alta (padrão RFC) | Média (precisa configurar sticky sessions) | Alta |
| Tamanho do pacote (client) | ~2 KB | ~45 KB (minificado) | N/A (sem client oficial) |
Para notificações, onde o payload é pequeno e o servidor envia mais do que recebe, ws é a escolha direta. Socket.IO adiciona complexidade (rooms, namespaces, protocolo próprio) que não justifica o overhead quando você não precisa de fallback para navegadores antigos. uWebSockets.js entrega performance superior, mas a API é menos ergonômica e a manutenção do projeto é irregular.
Este post usa ws no backend e a API nativa WebSocket do navegador no frontend.
Servidor WebSocket com autenticação no handshake
A autenticação acontece antes de completar o upgrade HTTP para WebSocket. Se você autenticar depois, qualquer cliente anônimo consegue abrir uma conexão e consumir recursos do servidor até ser desconectado.
// server.ts
import { WebSocketServer, WebSocket } from "ws";
import { createServer } from "http";
import { verify, JwtPayload } from "jsonwebtoken";
const JWT_SECRET = process.env.JWT_SECRET!;
const PORT = Number(process.env.PORT) || 3001;
const httpServer = createServer();
const wss = new WebSocketServer({ noServer: true });
// Map de userId -> Set de conexões (um usuário pode ter múltiplas abas)
const userConnections = new Map<string, Set<WebSocket>>();
// Autenticação acontece no upgrade, ANTES de aceitar a conexão
httpServer.on("upgrade", (request, socket, head) => {
const url = new URL(request.url!, `http://${request.headers.host}`);
const token = url.searchParams.get("token");
if (!token) {
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
socket.destroy();
return;
}
try {
const payload = verify(token, JWT_SECRET) as JwtPayload & { userId: string };
wss.handleUpgrade(request, socket, head, (ws) => {
// Anexa userId ao objeto ws para uso posterior
(ws as any).userId = payload.userId;
wss.emit("connection", ws, request);
});
} catch {
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
socket.destroy();
}
});
wss.on("connection", (ws: WebSocket) => {
const userId = (ws as any).userId as string;
// Registra a conexão no map do usuário
if (!userConnections.has(userId)) {
userConnections.set(userId, new Set());
}
userConnections.get(userId)!.add(ws);
console.log(`Usuário ${userId} conectado. Total de conexões: ${userConnections.get(userId)!.size}`);
ws.on("close", () => {
const connections = userConnections.get(userId);
if (connections) {
connections.delete(ws);
if (connections.size === 0) {
userConnections.delete(userId);
}
}
});
// Heartbeat: detecta conexões zumbis que o TCP não fechou
ws.on("pong", () => {
(ws as any).isAlive = true;
});
(ws as any).isAlive = true;
});
// Intervalo de heartbeat: desconecta clientes que não responderam ao ping anterior
const heartbeatInterval = setInterval(() => {
wss.clients.forEach((ws) => {
if ((ws as any).isAlive === false) {
ws.terminate();
return;
}
(ws as any).isAlive = false;
ws.ping();
});
}, 30_000);
wss.on("close", () => clearInterval(heartbeatInterval));
httpServer.listen(PORT, () => {
console.log(`WebSocket server rodando na porta ${PORT}`);
});
export { userConnections };O noServer: true é intencional. Ele separa o servidor HTTP do WebSocket, permitindo que a autenticação rejeite conexões antes do upgrade completar. Sem isso, o cliente já estaria conectado quando você tentasse validar o token.
Enviando notificações para usuários específicos
A função de envio precisa lidar com dois cenários: o usuário está online (envia via WebSocket) ou não está (persiste para entrega posterior). A persistência é responsabilidade da sua camada de dados, não do WebSocket.
// notifications.ts
import { WebSocket } from "ws";
import { userConnections } from "./server";
interface Notification {
id: string;
type: "info" | "warning" | "error" | "success";
title: string;
body: string;
createdAt: string;
}
export function sendNotification(userId: string, notification: Notification): boolean {
const connections = userConnections.get(userId);
if (!connections || connections.size === 0) {
// Usuário offline: persista a notificação no banco
// para entrega quando reconectar
return false;
}
const payload = JSON.stringify({
event: "notification",
data: notification,
});
// Envia para todas as abas/dispositivos do usuário
connections.forEach((ws) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(payload);
}
});
return true;
}
export function broadcastToAll(notification: Notification): void {
const payload = JSON.stringify({
event: "notification",
data: notification,
});
userConnections.forEach((connections) => {
connections.forEach((ws) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(payload);
}
});
});
}A checagem de readyState === WebSocket.OPEN antes de cada send não é paranoia. Entre o momento que você itera o Set e chama send, a conexão pode ter sido fechada por timeout de rede ou pelo cliente. Sem essa checagem, o send lança uma exceção.
Integrando com uma API REST existente
O cenário real: você tem uma API Express que processa ações (novo pedido, comentário, deploy finalizado) e precisa disparar notificações. O servidor WebSocket roda no mesmo processo ou em processo separado, dependendo da escala.
Para menos de 10k conexões simultâneas, rode no mesmo processo. Acima disso, separe o WebSocket server e use Redis Pub/Sub para comunicação entre processos, conforme descrito na documentação de segurança de APIs Node.js.
// api.ts
import express from "express";
import { randomUUID } from "crypto";
import { sendNotification } from "./notifications";
const app = express();
app.use(express.json());
app.post("/api/orders", async (req, res) => {
const { userId, items } = req.body;
// ... lógica de criação do pedido no banco ...
const orderId = randomUUID();
// Dispara notificação via WebSocket após persistir
const delivered = sendNotification(userId, {
id: randomUUID(),
type: "success",
title: "Pedido confirmado",
body: `Seu pedido #${orderId.slice(0, 8)} foi recebido.`,
createdAt: new Date().toISOString(),
});
if (!delivered) {
// Persiste para entrega posterior (push notification, email, etc.)
// await saveUndeliveredNotification(userId, notification);
}
res.status(201).json({ orderId });
});
app.listen(3000);A separação entre "entregar via WebSocket" e "persistir para entrega posterior" é uma decisão arquitetural que aparece em qualquer sistema de notificações sério. Se você precisa de garantia de entrega, o WebSocket é canal de conveniência, não de persistência. A fonte de verdade fica no banco. Para quem está estruturando essa separação de responsabilidades, o padrão de CQRS com Event Sourcing oferece uma base sólida para modelar o fluxo de eventos.
Hook de WebSocket no React com reconexão automática
O cliente precisa de três capacidades: conectar com token, reconectar automaticamente quando a conexão cai e expor as notificações para qualquer componente da árvore.
// hooks/useWebSocket.ts
import { useEffect, useRef, useCallback, useState } from "react";
interface WebSocketMessage {
event: string;
data: unknown;
}
interface UseWebSocketOptions {
url: string;
token: string;
onMessage: (message: WebSocketMessage) => void;
// Teto de 30s evita backoff infinito em redes instáveis
maxReconnectDelay?: number;
}
export function useWebSocket({ url, token, onMessage, maxReconnectDelay = 30_000 }: UseWebSocketOptions) {
const wsRef = useRef<WebSocket | null>(null);
const reconnectAttempt = useRef(0);
const reconnectTimeout = useRef<ReturnType<typeof setTimeout>>();
const [status, setStatus] = useState<"connecting" | "connected" | "disconnected">("disconnected");
// Ref para onMessage evita que mudanças no callback causem reconexão
const onMessageRef = useRef(onMessage);
onMessageRef.current = onMessage;
const connect = useCallback(() => {
// Limpa conexão anterior se existir
if (wsRef.current) {
wsRef.current.close();
}
setStatus("connecting");
const ws = new WebSocket(`${url}?token=${token}`);
wsRef.current = ws;
ws.onopen = () => {
setStatus("connected");
reconnectAttempt.current = 0;
};
ws.onmessage = (event) => {
try {
const parsed = JSON.parse(event.data) as WebSocketMessage;
onMessageRef.current(parsed);
} catch {
// Mensagem malformada: ignora silenciosamente
// Em produção, envie para seu sistema de observabilidade
}
};
ws.onclose = (event) => {
setStatus("disconnected");
// Código 1000 = fechamento intencional, não reconecta
if (event.code === 1000) return;
// Exponential backoff com jitter para evitar thundering herd
const baseDelay = Math.min(1000 * 2 ** reconnectAttempt.current, maxReconnectDelay);
const jitter = baseDelay * 0.3 * Math.random();
const delay = baseDelay + jitter;
reconnectAttempt.current += 1;
reconnectTimeout.current = setTimeout(connect, delay);
};
ws.onerror = () => {
// O evento error sempre precede close no browser
// O tratamento real acontece no onclose
};
}, [url, token, maxReconnectDelay]);
useEffect(() => {
connect();
return () => {
clearTimeout(reconnectTimeout.current);
if (wsRef.current) {
wsRef.current.close(1000, "Component unmounted");
}
};
}, [connect]);
return { status };
}O jitter no backoff é obrigatório. Sem ele, quando o servidor reinicia, todos os clientes reconectam no mesmo instante (thundering herd), derrubando o servidor novamente. O cálculo baseDelay * 0.3 * Math.random() distribui as reconexões em uma janela de 30% do delay base.
Contexto de notificações e componente de UI
// contexts/NotificationContext.tsx
import { createContext, useContext, useState, useCallback, type ReactNode } from "react";
import { useWebSocket } from "../hooks/useWebSocket";
interface Notification {
id: string;
type: "info" | "warning" | "error" | "success";
title: string;
body: string;
createdAt: string;
read: boolean;
}
interface NotificationContextValue {
notifications: Notification[];
unreadCount: number;
markAsRead: (id: string) => void;
connectionStatus: "connecting" | "connected" | "disconnected";
}
const NotificationContext = createContext<NotificationContextValue | null>(null);
export function NotificationProvider({ token, children }: { token: string; children: ReactNode }) {
const [notifications, setNotifications] = useState<Notification[]>([]);
const handleMessage = useCallback((message: { event: string; data: unknown }) => {
if (message.event === "notification") {
const notification = message.data as Omit<Notification, "read">;
setNotifications((prev) => [{ ...notification, read: false }, ...prev]);
}
}, []);
const { status } = useWebSocket({
url: process.env.NEXT_PUBLIC_WS_URL || "ws://localhost:3001",
token,
onMessage: handleMessage,
});
const markAsRead = useCallback((id: string) => {
setNotifications((prev) =>
prev.map((n) => (n.id === id ? { ...n, read: true } : n))
);
}, []);
const unreadCount = notifications.filter((n) => !n.read).length;
return (
<NotificationContext.Provider value={{ notifications, unreadCount, markAsRead, connectionStatus: status }}>
{children}
</NotificationContext.Provider>
);
}
export function useNotifications() {
const ctx = useContext(NotificationContext);
if (!ctx) throw new Error("useNotifications deve ser usado dentro de NotificationProvider");
return ctx;
}A renderização da lista de notificações fica simples quando o contexto já entrega os dados prontos. Para estilização, o Design System com Radix UI e Tailwind oferece primitivos de dropdown e popover que encaixam diretamente nesse componente.
// components/NotificationBell.tsx
import { useNotifications } from "../contexts/NotificationContext";
export function NotificationBell() {
const { notifications, unreadCount, markAsRead, connectionStatus } = useNotifications();
return (
<div className="relative">
<button aria-label={`Notificações: ${unreadCount} não lidas`}>
<BellIcon />
{unreadCount > 0 && (
<span className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</button>
{connectionStatus === "disconnected" && (
<span className="text-yellow-600 text-xs">Reconectando...</span>
)}
<ul>
{notifications.slice(0, 20).map((notification) => (
<li
key={notification.id}
onClick={() => markAsRead(notification.id)}
className={notification.read ? "opacity-60" : "font-semibold"}
>
<strong>{notification.title}</strong>
<p>{notification.body}</p>
</li>
))}
</ul>
</div>
);
}
function BellIcon() {
return <svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor"><path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6v-5c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.64 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z" /></svg>;
}Anti-patterns que quebram em produção
Erro 1: armazenar estado no objeto WebSocket do servidor
// ERRADO: estado acoplado à conexão
ws.on("message", (data) => {
const parsed = JSON.parse(data.toString());
// Acumula notificações na conexão — perde tudo quando reconecta
(ws as any).notifications.push(parsed);
});Quando a conexão cai e o cliente reconecta, o objeto ws anterior é destruído junto com todo estado. O novo ws começa vazio.
// CORRETO: estado no Map externo, indexado por userId
const userNotifications = new Map<string, Notification[]>();
ws.on("message", (data) => {
const parsed = JSON.parse(data.toString());
const userId = (ws as any).userId;
const existing = userNotifications.get(userId) || [];
existing.push(parsed);
userNotifications.set(userId, existing);
});Na prática, esse Map deveria ser um banco de dados. O Map em memória serve para demonstração, mas em produção, um restart do processo apaga tudo. Use Postgres, Redis ou qualquer store persistente, como descrito em Database Migrations Seguras com Prisma.
Erro 2: reconexão sem backoff
// ERRADO: reconexão imediata sem delay
ws.onclose = () => {
// Todos os clientes reconectam simultaneamente
connect();
};Se o servidor caiu, 10 mil clientes tentando reconectar no mesmo milissegundo garantem que ele não volta. O exponential backoff com jitter mostrado no hook resolve isso.
Erro 3: não validar mensagens recebidas
// ERRADO: confia cegamente no payload
ws.on("message", (data) => {
const { action, target } = JSON.parse(data.toString());
// Executa qualquer ação que o cliente enviar
executeAction(action, target);
});WebSocket não tem middleware de validação embutido. Toda mensagem recebida do cliente deve ser validada com a mesma rigidez que você aplica em endpoints REST. Use Zod, como detalhado no post sobre API Layers entre fetch e produção.
// CORRETO: valida antes de processar
import { z } from "zod";
const ClientMessageSchema = z.discriminatedUnion("event", [
z.object({ event: z.literal("mark_read"), data: z.object({ notificationId: z.string().uuid() }) }),
z.object({ event: z.literal("ping"), data: z.undefined() }),
]);
ws.on("message", (raw) => {
const result = ClientMessageSchema.safeParse(JSON.parse(raw.toString()));
if (!result.success) {
ws.send(JSON.stringify({ event: "error", data: "Mensagem inválida" }));
return;
}
// Processa apenas eventos conhecidos e validados
handleClientEvent(result.data);
});Escalando além de um processo
O ws roda no event loop do Node.js (V8 para execução JavaScript, libuv para I/O assíncrono). Um único processo suporta entre 10k e 50k conexões WebSocket simultâneas, dependendo da frequência de mensagens e do tamanho do payload.
Quando você precisa de mais, a abordagem padrão é rodar múltiplas instâncias do servidor WebSocket atrás de um load balancer com sticky sessions (ou sem sticky sessions se usar Redis Pub/Sub para sincronizar estado entre instâncias). O deploy na edge com Cloudflare Workers não suporta WebSockets de longa duração no plano gratuito, então para WebSocket persistente, prefira VMs ou containers.
Para o client HTTP que busca notificações perdidas durante desconexões, a resiliência com retry e timeout se aplica diretamente.
Quando WebSocket é a escolha errada
WebSocket não é a resposta para todo cenário de "tempo real". Se o servidor envia dados e o cliente só recebe (dashboard de métricas, feed de notificações read-only), Server-Sent Events (SSE) é mais simples: funciona sobre HTTP/2, reconecta automaticamente no navegador, não precisa de biblioteca no servidor. Se você precisa de bidirecionalidade real (chat, colaboração em documento, jogos), WebSocket justifica a complexidade.
Para feature toggles que mudam raramente, Feature Flags com polling longo é suficiente e mais simples de operar.
FAQ
Preciso de Socket.IO ou ws puro resolve?
Se todos os seus clientes são navegadores modernos (Chrome 16+, Firefox 11+, Safari 7+), ws puro resolve. Socket.IO justifica quando você precisa de fallback para long-polling (ambientes corporativos com proxies que bloqueiam upgrade WebSocket) ou rooms/namespaces embutidos. Para notificações simples, o overhead do Socket.IO não compensa.
Como testo WebSocket localmente?
Use wscat (instalável via npm i -g wscat) para testar o servidor manualmente: wscat -c "ws://localhost:3001?token=SEU_JWT". Para testes automatizados, instancie o servidor em beforeAll, conecte com a classe WebSocket do pacote ws e valide as mensagens recebidas. O padrão de testes com Vitest se aplica com adaptações para o lifecycle assíncrono do WebSocket.
O token JWT na query string não é inseguro?
O token na query string aparece em logs de proxy e no histórico do navegador. Em produção, use wss:// (WebSocket sobre TLS) para criptografar a URL em trânsito. Para eliminar o token da URL completamente, envie-o na primeira mensagem após o onopen, mas isso significa que a conexão fica aberta sem autenticação por alguns milissegundos, o que exige um timeout curto no servidor para desconectar clientes que não autenticam.
Como garanto que notificações não se perdem durante desconexões?
Persista toda notificação no banco de dados antes de tentar enviar via WebSocket. Quando o cliente reconecta, faça uma requisição HTTP para buscar notificações não lidas desde o último lastSeenTimestamp. O WebSocket é canal de entrega otimista, não fonte de verdade.
SSE ou WebSocket para um dashboard de métricas?
SSE. O dashboard só recebe dados, não envia. SSE reconecta automaticamente, funciona com HTTP/2 multiplexing, não precisa de biblioteca extra no servidor (é um text/event-stream sobre Express ou qualquer framework HTTP) e passa por proxies sem configuração especial.
A posição que defendo
WebSocket é infraestrutura de transporte, não de persistência. O erro mais comum que aparece em implementações de notificação é tratar a conexão WebSocket como fila de mensagens: se o send executou, a notificação foi entregue. Não foi. O cliente pode ter crashado entre o onmessage e a renderização. A rede pode ter engolido o pacote.
Trate o WebSocket como otimização de latência sobre um sistema que já funciona sem ele. Primeiro, faça o polling funcionar. Depois, adicione WebSocket para eliminar a latência e reduzir carga no servidor. Se o WebSocket cair, o sistema degrada para polling, não para silêncio. Essa é a diferença entre um sistema de notificações que funciona em demo e um que funciona em produção.

Escrito por
Marcos Soares
Fullstack Developer · CEO da Agência Poti
Fullstack Developer e CEO da Agência Poti. Mais de 20 anos construindo arquiteturas cloud-native com React, Next.js e sistemas distribuídos. Parceiro comercial do estúdio iellou design. Fundador do Vivo de Código.
Comentários
Participe da discussão
Seja o primeiro a comentar!
Continue Aprofundando

WebSockets vs Server-Sent Events: quando usar cada um

Arquitetando Comunicação em Tempo Real em Escala: Redis Pub/Sub, Load Balancing e Milhares de Conexões Simultâneas

Socket.IO do Zero: Chat Completo com Salas, Typing Indicator e Histórico de Mensagens
Guias de integração relacionados
Conteúdo técnico toda semana
Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.
Sem spam. Cancele a qualquer momento com 1 clique.