WebSockets vs Server-Sent Events: quando usar cada um

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.
## O problema: comunicação em tempo real
HTTP foi desenhado como request-response. O cliente pede, o servidor responde. Fim.
Mas aplicações modernas precisam de dados fluindo continuamente. Notificações, dashboards, chats, feeds ao vivo. O modelo clássico não resolve isso de forma elegante.
Duas tecnologias dominam esse espaço: **WebSockets** e **Server-Sent Events (SSE)**. Ambas resolvem o problema de "push" do servidor para o cliente. Mas funcionam de formas radicalmente diferentes.
Escolher errado custa caro. Custa em complexidade, em infraestrutura e em bugs que só aparecem em produção.
## Entendendo o protocolo por baixo
Antes de comparar, você precisa entender o que cada tecnologia realmente faz na camada de rede.
### WebSockets: conexão bidirecional persistente
WebSocket começa como uma requisição HTTP normal. O cliente envia um header `Upgrade: websocket`. Se o servidor aceita, o protocolo muda.
A partir daí, a conexão TCP permanece aberta. Cliente e servidor enviam frames binários ou texto em qualquer direção. Sem overhead de headers HTTP em cada mensagem.
É full-duplex. Ambos os lados falam quando quiserem.
### SSE: stream unidirecional sobre HTTP
Server-Sent Events usa HTTP puro. O servidor responde com `Content-Type: text/event-stream` e mantém a conexão aberta.
O servidor envia eventos como texto plano. O cliente recebe via a API `EventSource` do navegador. Apenas o servidor envia dados. O cliente só escuta.
É half-duplex por design. E isso não é uma limitação. É uma feature.
## Configurando o ambiente
Vamos construir exemplos reais com Node.js. Crie o projeto:
```bash
mkdir realtime-demo && cd realtime-demo
npm init -y
npm install express wsA estrutura será simples:
realtime-demo/
├── server-ws.js
├── server-sse.js
├── client-ws.html
└── client-sse.htmlImplementação: Server-Sent Events
Começamos pelo SSE porque é mais simples. Essa simplicidade é o ponto.
Servidor SSE
// server-sse.js
import express from "express";
import { randomUUID } from "crypto";
const app = express();
const PORT = 3001;
// Armazena conexões ativas
const clients = new Map();
app.get("/", (req, res) => {
res.sendFile(new URL("./client-sse.html", import.meta.url).pathname);
});
app.get("/events", (req, res) => {
const clientId = randomUUID();
// Headers obrigatórios para SSE
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
});
// Envia um comentário como heartbeat inicial
res.write(": connected\n\n");
// Registra o cliente
clients.set(clientId, res);
console.log(`Cliente ${clientId} conectado. Total: ${clients.size}`);
// Remove ao desconectar
req.on("close", () => {
clients.delete(clientId);
console.log(`Cliente ${clientId} desconectou. Total: ${clients.size}`);
});
});
// Endpoint para simular envio de dados
app.post("/broadcast", express.json(), (req, res) => {
const event = {
id: Date.now().toString(),
type: req.body.type || "message",
data: JSON.stringify(req.body.data),
};
for (const [clientId, clientRes] of clients) {
clientRes.write(`id: ${event.id}\n`);
clientRes.write(`event: ${event.type}\n`);
clientRes.write(`data: ${event.data}\n\n`);
}
res.json({ sent: clients.size });
});
// Simula dados de mercado a cada 2 segundos
setInterval(() => {
const price = (100 + Math.random() * 50).toFixed(2);
const payload = JSON.stringify({ symbol: "PETR4", price, timestamp: Date.now() });
for (const [, clientRes] of clients) {
clientRes.write(`event: stock-update\n`);
clientRes.write(`data: ${payload}\n\n`);
}
}, 2000);
app.listen(PORT, () => {
console.log(`SSE Server rodando em http://localhost:${PORT}`);
});Cliente SSE
<!-- client-sse.html -->
<!DOCTYPE html>
<html>
<head><title>SSE Client</title></head>
<body>
<h1>Server-Sent Events Demo</h1>
<div id="status">Desconectado</div>
<ul id="messages"></ul>
<script>
const status = document.getElementById('status');
const messages = document.getElementById('messages');
const source = new EventSource('/events');
source.onopen = () => {
status.textContent = 'Conectado';
status.style.color = 'green';
};
// Escuta evento genérico
source.onmessage = (event) => {
addMessage('message', event.data);
};
// Escuta evento tipado
source.addEventListener('stock-update', (event) => {
const data = JSON.parse(event.data);
addMessage('stock', `${data.symbol}: R$ ${data.price}`);
});
source.onerror = () => {
status.textContent = 'Reconectando...';
status.style.color = 'orange';
// EventSource reconecta automaticamente!
};
function addMessage(type, text) {
const li = document.createElement('li');
li.textContent = `[${type}] ${text}`;
messages.prepend(li);
if (messages.children.length > 50) messages.lastChild.remove();
}
</script>
</body>
</html>Repare em algo crucial: EventSource reconecta automaticamente. Se a conexão cair, o navegador tenta novamente. Ele até envia o header Last-Event-ID para que o servidor saiba de onde retomar.
Isso é de graça. Você não escreve uma linha de código para ter essa resiliência.
Implementação: WebSockets
Agora a versão WebSocket do mesmo conceito.
Servidor WebSocket
// server-ws.js
import express from "express";
import { WebSocketServer } from "ws";
import { createServer } from "http";
import { randomUUID } from "crypto";
const app = express();
const server = createServer(app);
const wss = new WebSocketServer({ server });
const PORT = 3002;
const clients = new Map();
app.get("/", (req, res) => {
res.sendFile(new URL("./client-ws.html", import.meta.url).pathname);
});
wss.on("connection", (ws) => {
const clientId = randomUUID();
clients.set(clientId, ws);
console.log(`Cliente ${clientId} conectado. Total: ${clients.size}`);
// Envia confirmação
ws.send(JSON.stringify({ type: "connected", clientId }));
// Recebe mensagens do cliente (bidirecional!)
ws.on("message", (raw) => {
try {
const msg = JSON.parse(raw);
switch (msg.type) {
case "chat":
// Broadcast para todos os outros
for (const [id, client] of clients) {
if (id !== clientId && client.readyState === 1) {
client.send(JSON.stringify({
type: "chat",
from: clientId.slice(0, 8),
text: msg.text,
timestamp: Date.now(),
}));
}
}
break;
case "ping":
ws.send(JSON.stringify({ type: "pong", timestamp: Date.now() }));
break;
}
} catch (err) {
ws.send(JSON.stringify({ type: "error", message: "JSON inválido" }));
}
});
ws.on("close", () => {
clients.delete(clientId);
console.log(`Cliente ${clientId} desconectou. Total: ${clients.size}`);
});
// Heartbeat para detectar conexões mortas
ws.isAlive = true;
ws.on("pong", () => { ws.isAlive = true; });
});
// Heartbeat interval: essencial em produção
const heartbeat = setInterval(() => {
for (const [clientId, ws] of clients) {
if (!ws.isAlive) {
ws.terminate();
clients.delete(clientId);
return;
}
ws.isAlive = false;
ws.ping();
}
}, 30000);
wss.on("close", () => clearInterval(heartbeat));
// Simula dados de mercado
setInterval(() => {
const price = (100 + Math.random() * 50).toFixed(2);
const payload = JSON.stringify({
type: "stock-update",
symbol: "PETR4",
price,
timestamp: Date.now(),
});
for (const [, ws] of clients) {
if (ws.readyState === 1) ws.send(payload);
}
}, 2000);
server.listen(PORT, () => {
console.log(`WebSocket Server rodando em http://localhost:${PORT}`);
});Cliente WebSocket
<!-- client-ws.html -->
<!DOCTYPE html>
<html>
<head><title>WebSocket Client</title></head>
<body>
<h1>WebSocket Demo</h1>
<div id="status">Desconectado</div>
<input id="input" placeholder="Digite uma mensagem..." />
<button onclick="sendChat()">Enviar</button>
<ul id="messages"></ul>
<script>
const status = document.getElementById('status');
const messages = document.getElementById('messages');
const input = document.getElementById('input');
let ws;
let reconnectAttempts = 0;
const MAX_RECONNECT = 5;
function connect() {
ws = new WebSocket(`ws://${location.host}`);
ws.onopen = () => {
status.textContent = 'Conectado';
status.style.color = 'green';
reconnectAttempts = 0;
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
switch (msg.type) {
case 'stock-update':
addMessage('stock', `${msg.symbol}: R$ ${msg.price}`);
break;
case 'chat':
addMessage('chat', `${msg.from}: ${msg.text}`);
break;
case 'connected':
addMessage('system', `ID: ${msg.clientId}`);
break;
}
};
ws.onclose = () => {
status.textContent = 'Desconectado';
status.style.color = 'red';
// Reconexão manual com backoff exponencial
if (reconnectAttempts < MAX_RECONNECT) {
const delay = Math.pow(2, reconnectAttempts) * 1000;
reconnectAttempts++;
status.textContent = `Reconectando em ${delay / 1000}s...`;
setTimeout(connect, delay);
}
};
ws.onerror = () => ws.close();
}
function sendChat() {
if (ws.readyState === 1 && input.value.trim()) {
ws.send(JSON.stringify({ type: 'chat', text: input.value }));
addMessage('you', input.value);
input.value = '';
}
}
input.addEventListener('keypress', (e) => {
if (e.key === 'Enter') sendChat();
});
function addMessage(type, text) {
const li = document.createElement('li');
li.textContent = `[${type}] ${text}`;
messages.prepend(li);
if (messages.children.length > 50) messages.lastChild.remove();
}
connect();
</script>
</body>
</html>Percebeu a diferença? No WebSocket, você implementa reconexão manualmente. Heartbeat manualmente. Protocolo de mensagens manualmente. Nada vem de graça.
Comparação técnica direta
Vamos colocar os dois lado a lado nos critérios que importam.
Direção da comunicação
| Critério | WebSocket | SSE |
|---|---|---|
| Servidor → Cliente | ✅ | ✅ |
| Cliente → Servidor | ✅ | ❌ (usa HTTP normal) |
| Full-duplex | ✅ | ❌ |
SSE é unidirecional. Mas na prática? O cliente pode enviar dados via fetch ou POST normal. Você não precisa de um canal bidirecional persistente para enviar um formulário.
Resiliência de conexão
SSE vence aqui. Sem discussão.
EventSource reconecta automaticamente com o header Last-Event-ID. O servidor pode retransmitir eventos perdidos. Tudo nativo, sem bibliotecas.
WebSocket exige que você implemente reconexão, backoff exponencial, heartbeat e detecção de conexões zumbi. Cada uma dessas coisas é uma fonte de bugs.
Compatibilidade com infraestrutura
SSE funciona sobre HTTP/1.1 padrão. Passa por qualquer proxy, CDN, load balancer ou firewall sem configuração especial.
WebSocket precisa do upgrade de protocolo. Proxies corporativos bloqueiam. Alguns load balancers precisam de configuração explícita. CloudFlare, nginx e AWS ALB suportam, mas exigem ajustes.
# nginx.conf: configuração necessária para WebSocket
location /ws {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400;
}
# nginx.conf: SSE funciona sem nada especial
location /events {
proxy_pass http://backend;
proxy_set_header Connection '';
proxy_http_version 1.1;
chunked_transfer_encoding off;
proxy_buffering off;
proxy_cache off;
}Limite de conexões
HTTP/1.1 tem limite de 6 conexões simultâneas por domínio no navegador. Cada aba com SSE consome uma.
HTTP/2 multiplexa tudo em uma única conexão TCP. SSE sobre HTTP/2 não tem esse problema.
WebSocket não é multiplexado por HTTP/2. Cada conexão WebSocket é uma conexão TCP separada. Sempre.
Formato de dados
SSE transmite apenas texto (UTF-8). Para binário, você precisa encodar em Base64.
WebSocket suporta texto e binário nativamente. Para streaming de áudio, vídeo ou arquivos, WebSocket é a escolha óbvia.
Quando usar SSE
Use Server-Sent Events quando o fluxo de dados é predominantemente do servidor para o cliente:
- Dashboards em tempo real: métricas, gráficos, KPIs
- Notificações: alertas, updates de status
- Feeds ao vivo: news feed, timeline, logs
- Progresso de tarefas: uploads, processamento assíncrono
- Streaming de LLMs: ChatGPT, Claude e similares usam SSE
Esse último ponto é relevante. A API da OpenAI usa SSE para streaming de tokens. Não WebSocket. Existe uma razão para isso.
// Exemplo: streaming de resposta de IA via SSE
app.get("/ai/stream", async (req, res) => {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
});
const prompt = req.query.prompt;
const tokens = prompt.split(" "); // Simulação
for (const token of tokens) {
res.write(`data: ${JSON.stringify({ token })}\n\n`);
await new Promise((r) => setTimeout(r, 100));
}
res.write(`data: [DONE]\n\n`);
res.end();
});Quando usar WebSocket
Use WebSocket quando a comunicação é genuinamente bidirecional e de alta frequência:
- Chat em tempo real: mensagens fluindo nos dois sentidos
- Jogos multiplayer: estado compartilhado com baixa latência
- Editores colaborativos: Google Docs, Figma
- Trading platforms: ordens + cotações simultâneas
- IoT: dispositivos que enviam e recebem comandos
A pergunta-chave é: o cliente precisa enviar dados frequentes pelo mesmo canal?
Se a resposta é "não" ou "raramente", SSE é suficiente.
Escalabilidade: o elefante na sala
Ambas as tecnologias mantêm conexões persistentes. Isso muda completamente como você escala.
O problema com estado
Servidores HTTP tradicionais são stateless. Qualquer instância responde qualquer request. Load balancing é trivial.
Conexões persistentes são stateful. O cliente está conectado a uma instância específica. Se essa instância cai, a conexão morre.
Soluções para SSE
SSE reconecta automaticamente. O load balancer direciona para outra instância. O Last-Event-ID permite retomar de onde parou.
Para isso funcionar, você precisa de um broker de mensagens compartilhado:
// Usando Redis Pub/Sub para distribuir eventos entre instâncias
import Redis from "ioredis";
const subscriber = new Redis();
const publisher = new Redis();
subscriber.subscribe("events");
subscriber.on("message", (channel, message) => {
// Envia para todos os clientes SSE desta instância
for (const [, clientRes] of clients) {
clientRes.write(`data: ${message}\n\n`);
}
});
// Qualquer instância pode publicar
app.post("/emit", express.json(), (req, res) => {
publisher.publish("events", JSON.stringify(req.body));
res.json({ ok: true });
});Soluções para WebSocket
WebSocket precisa de sticky sessions no load balancer. Ou uma camada de pub/sub como Redis.
Bibliotecas como Socket.IO resolvem isso com o adapter de Redis. Mas adicionam complexidade significativa.
npm install @socket.io/redis-adapter redisA complexidade operacional do WebSocket em produção é substancialmente maior que SSE.
Tabela de decisão rápida
| Cenário | Escolha | Motivo |
|---|---|---|
| Dashboard de métricas | SSE | Unidirecional, simples |
| Chat 1:1 ou em grupo | WebSocket | Bidirecional frequente |
| Notificações push | SSE | Servidor → cliente |
| Jogo multiplayer | WebSocket | Latência crítica, bidirecional |
| Streaming de IA/LLM | SSE | Unidirecional, HTTP nativo |
| Editor colaborativo | WebSocket | Bidirecional, alta frequência |
| Feed de preços | SSE | Unidirecional, reconexão nativa |
| Terminal remoto | WebSocket | Bidirecional, binário |
Minha Opinião Sincera
A indústria usa WebSocket demais. É a ferramenta "padrão" que todo mundo pega quando ouve "tempo real". Mas na maioria dos casos que encontro em produção, SSE seria suficiente e muito mais simples.
Eu já vi times gastarem semanas configurando Socket.IO com Redis adapter, sticky sessions no Kubernetes, health checks customizados para conexões WebSocket, tudo para um dashboard que só mostra dados do servidor para o cliente. SSE resolveria com 30 linhas de código.
Socket.IO especificamente é uma armadilha. Ele abstrai a complexidade, mas adiciona outra camada de complexidade própria. Fallback para polling, protocolo proprietário, versionamento de client/server. Se você precisa de WebSocket, use a API nativa ws. Se não precisa, use SSE.
A API EventSource do navegador é subestimada. Reconexão automática, Last-Event-ID, tipagem de eventos, tudo nativo, zero dependências. É uma das APIs mais bem desenhadas da web.
Minha regra é simples: comece com SSE. Migre para WebSocket apenas quando SSE comprovadamente não resolver. Nos meus últimos 5 projetos com requisitos de tempo real, 4 ficaram com SSE. O único que precisou de WebSocket era um editor colaborativo.
A exceção legítima é quando você precisa de dados binários ou latência abaixo de 50ms nos dois sentidos. Jogos, áudio, vídeo. Nesses casos, WebSocket é insubstituível.
Para todo o resto, SSE é a resposta que ninguém quer ouvir porque parece simples demais. E ser simples é exatamente o ponto.
## Leia tambem
- [Rate Limiting com Redis e Node.js](/backend/rate-limiting-inteligente-redis-nodejs-apis-producao)
- [Design Patterns em TypeScript](/backend/design-patterns-typescript-dev-senior)
- [Connection Pooling e PgBouncer](/backend/escalar-postgresql-connection-pooling-pgbouncer)
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 na Prática: Sistema de Notificações em Tempo Real com Node.js e React

Socket.IO do Zero: Chat Completo com Salas, Typing Indicator e Histórico de Mensagens

React Server Components vs Client Components: Quando Usar Cada Um no Next.js
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.