Dados em Tempo Real: WebSockets, SSE e Quando Você Realmente Precisa Deles
Compreendendo os padrões de dados em tempo real — de WebSockets a Server-Sent Events, Firebase Realtime Database e sabendo quando o polling é suficiente.

A maioria das aplicações que afirmam precisar de dados em tempo real, na verdade, não precisam de dados em tempo real. Elas precisam de dados que pareçam atuais — atualizados em poucos segundos, não em poucos milissegundos. A distinção é importante porque as arquiteturas verdadeiramente em tempo real introduzem complexidade no gerenciamento de conexão, sincronização de estado, escalabilidade e tratamento de erros que são totalmente desnecessárias se seus usuários ficarem perfeitamente satisfeitos com um atraso de cinco segundos.
Eu construí recursos genuinamente em tempo real — rastreamento de pedidos ao vivo para uma plataforma de restaurante, interfaces de edição colaborativa, painéis de controle ao vivo — e também construí recursos onde inicialmente super-engenhei com WebSockets e depois os substituí por polling que atualiza a cada dez segundos. Ninguém notou a mudança. Começar com a abordagem mais simples e adicionar complexidade apenas quando os usuários realmente experimentam um problema não é apenas pragmático — é a escolha de engenharia responsável.
Quando Você Realmente Precisa de Tempo Real
O tempo real verdadeiro é justificado quando o valor da informação se degrada rapidamente com a latência.
Chat e mensagens. Os usuários esperam que as mensagens apareçam em um segundo. Um atraso de cinco segundos faz com que uma conversa pareça quebrada. O tempo real não é opcional aqui.
Edição colaborativa. A edição simultânea no estilo Google Docs requer sincronização em sub-segundos para evitar edições conflitantes. A complexidade aqui vai além do transporte — você precisa de transformação operacional ou CRDTs para resolução de conflitos.
Painéis de controle ao vivo com impacto operacional. Um painel de monitoramento onde um engenheiro está observando anomalias durante uma implantação precisa de atualizações em tempo real. Um painel de análise de negócios visualizado uma vez por dia não precisa.
Jogos e experiências interativas. Jogos multiplayer, leilões ao vivo, pesquisas em tempo real — qualquer coisa onde múltiplos usuários interagem com um estado compartilhado simultaneamente.
Negociação financeira. Feeds de preços, atualizações de livro de ordens, mudanças de posição. Milissegundos importam aqui, e a arquitetura reflete isso.
Quando Você Não Precisa de Tempo Real
Feeds de redes sociais. Twitter e Instagram parecem em tempo real, mas usam uma combinação de polling no foco e notificações push. O feed não atualiza enquanto você está olhando para ele — você puxa para atualizar.
Inventário de e-commerce. "Restam apenas 3!" não precisa ser atualizado em tempo real. Verificar no carregamento da página e no checkout é suficiente.
Contagens de notificações. O distintivo vermelho mostrando "5 novas notificações" pode ser consultado a cada 30 segundos. Os usuários não percebem se uma notificação aparece 30 segundos depois de ter sido criada.
Atualizações de conteúdo. Postagens de blog, listagens de produtos, perfis de usuário — quaisquer dados que mudam com pouca frequência. Faça polling no carregamento da página ou use cabeçalhos de cache HTTP com revalidação.
Polling: O Padrão Subestimado
Polling — fazer requisições HTTP periódicas para verificar novos dados — é a abordagem mais simples e funciona para mais casos de uso do que a maioria dos desenvolvedores assume.
Polling Simples
function usePolledData<T>(url: string, intervalMs: number = 5000) {
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let active = true;
const fetchData = async () => {
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const result = await response.json();
if (active) setData(result);
} catch (e) {
if (active) setError(e as Error);
}
};
fetchData(); // Initial fetch
const interval = setInterval(fetchData, intervalMs);
return () => {
active = false;
clearInterval(interval);
};
}, [url, intervalMs]);
return { data, error };
}
Polling Inteligente com Requisições Condicionais
Requisições HTTP condicionais (If-None-Match com ETags ou If-Modified-Since) permitem que você faça polling de forma eficiente. O servidor retorna 304 Not Modified sem corpo se os dados não mudaram, reduzindo a largura de banda para quase zero para recursos inalterados.
async function pollWithETag(url: string): Promise<{ data: any; changed: boolean }> {
const cachedETag = etagCache.get(url);
const headers: HeadersInit = {};
if (cachedETag) {
headers['If-None-Match'] = cachedETag;
}
const response = await fetch(url, { headers });
if (response.status === 304) {
return { data: dataCache.get(url), changed: false };
}
const etag = response.headers.get('ETag');
if (etag) etagCache.set(url, etag);
const data = await response.json();
dataCache.set(url, data);
return { data, changed: true };
}
Quando o Polling Falha
O polling deixa de ser viável quando:
- Você precisa de latência em sub-segundos. Fazer polling a cada 500ms é essencialmente um ataque DoS à sua própria API.
- Os dados mudam raramente, mas precisam ser entregues instantaneamente. Fazer polling a cada 5 segundos para um evento que acontece uma vez por hora desperdiça 719 requisições por hora.
- Você tem milhares de clientes. Cada cliente de polling é uma conexão independente. Em escala, a sobrecarga HTTP de estabelecer conexões, analisar cabeçalhos e autenticar se acumula.
Server-Sent Events: O Tempo Real Mais Simples
Server-Sent Events (SSE) são a tecnologia de tempo real mais subutilizada no desenvolvimento web. SSE fornece um fluxo unidirecional do servidor para o cliente através de uma conexão HTTP padrão. O navegador lida com a reconexão automaticamente, e o protocolo é trivialmente simples.
Implementação do Servidor
// Express.js SSE endpoint
app.get('/events/orders/:restaurantId', (req, res) => {
const { restaurantId } = req.params;
// 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 data
const initialOrders = getActiveOrders(restaurantId);
res.write(`data: ${JSON.stringify(initialOrders)}\n\n`);
// Subscribe to order changes
const unsubscribe = orderEmitter.on(`orders:${restaurantId}`, (order) => {
res.write(`event: orderUpdate\n`);
res.write(`data: ${JSON.stringify(order)}\n`);
res.write(`id: ${order.id}-${order.updatedAt}\n\n`);
});
// Heartbeat to detect dead connections
const heartbeat = setInterval(() => {
res.write(`: heartbeat\n\n`);
}, 30000);
// Cleanup on disconnect
req.on('close', () => {
clearInterval(heartbeat);
unsubscribe();
});
});
Implementação do Cliente
function useSSE(url: string) {
const [data, setData] = useState(null);
const [connected, setConnected] = useState(false);
useEffect(() => {
const eventSource = new EventSource(url);
eventSource.onopen = () => setConnected(true);
eventSource.onmessage = (event) => {
setData(JSON.parse(event.data));
};
eventSource.addEventListener('orderUpdate', (event) => {
const order = JSON.parse(event.data);
setData(prev => updateOrderInList(prev, order));
});
eventSource.onerror = () => {
setConnected(false);
// EventSource automatically reconnects
};
return () => eventSource.close();
}, [url]);
return { data, connected };
}
Por Que SSE em Vez de WebSockets
Reconexão automática. A implementação de EventSource do navegador lida com a reconexão com backoff exponencial automaticamente. Com WebSockets, você implementa isso sozinho.
Funciona através de proxies e balanceadores de carga. SSE usa HTTP padrão, então funciona através de nginx, CloudFlare e a maioria dos proxies reversos sem configuração especial. WebSockets exigem suporte explícito de proxy.
Autenticação mais simples. As conexões SSE carregam cookies e podem usar cabeçalhos de autenticação HTTP padrão. A autenticação WebSocket requer o envio de credenciais após o estabelecimento da conexão, o que é um protocolo adicional a ser implementado.
Tipos de evento e IDs integrados. O protocolo SSE suporta eventos nomeados e IDs de mensagem, o que permite ao cliente retomar de onde parou após uma reconexão.
Limitações do SSE
- Apenas unidirecional. Do servidor para o cliente. Se você precisa de comunicação bidirecional, o SSE não pode fazer isso. Você pode emparelhar SSE com requisições HTTP POST regulares para mensagens cliente-servidor, o que funciona bem para muitos casos de uso.
- Limites de conexão do navegador. Os navegadores limitam o número de conexões HTTP simultâneas a um único domínio (geralmente seis). Cada conexão SSE conta para esse limite. A multiplexação HTTP/2 mitiga isso, mas vale a pena estar ciente.
- Sem dados binários. SSE é apenas texto. Se você precisa transmitir dados binários (áudio, vídeo, arquivos), use WebSockets ou um mecanismo separado.
WebSockets: Comunicação Full Duplex
WebSockets fornecem comunicação full-duplex — tanto o cliente quanto o servidor podem enviar mensagens a qualquer momento sem a sobrecarga dos ciclos de requisição/resposta HTTP. Isso é necessário quando você precisa de comunicação bidirecional e de baixa latência.
Implementação do Servidor com ws
import { WebSocketServer, WebSocket } from 'ws';
import { createServer } from 'http';
const server = createServer();
const wss = new WebSocketServer({ server });
// Connection management
const rooms = new Map<string, Set<WebSocket>>();
wss.on('connection', (ws, req) => {
// Authenticate the connection
const token = new URL(req.url!, `http://${req.headers.host}`).searchParams.get('token');
const user = verifyToken(token);
if (!user) {
ws.close(4001, 'Unauthorized');
return;
}
// Attach user data
(ws as any).userId = user.id;
ws.on('message', (raw) => {
try {
const message = JSON.parse(raw.toString());
handleMessage(ws, user, message);
} catch (e) {
ws.send(JSON.stringify({ error: 'Invalid message format' }));
}
});
ws.on('close', () => {
// Remove from all rooms
rooms.forEach((clients, roomId) => {
clients.delete(ws);
if (clients.size === 0) rooms.delete(roomId);
});
});
// Heartbeat
(ws as any).isAlive = true;
ws.on('pong', () => { (ws as any).isAlive = true; });
});
// Dead connection detection
const heartbeatInterval = setInterval(() => {
wss.clients.forEach((ws) => {
if (!(ws as any).isAlive) {
ws.terminate();
return;
}
(ws as any).isAlive = false;
ws.ping();
});
}, 30000);
function handleMessage(ws: WebSocket, user: any, message: any) {
switch (message.type) {
case 'join_room':
joinRoom(ws, message.roomId);
break;
case 'leave_room':
leaveRoom(ws, message.roomId);
break;
case 'broadcast':
broadcastToRoom(message.roomId, {
type: 'message',
userId: user.id,
content: message.content,
timestamp: Date.now(),
}, ws);
break;
}
}
function joinRoom(ws: WebSocket, roomId: string) {
if (!rooms.has(roomId)) rooms.set(roomId, new Set());
rooms.get(roomId)!.add(ws);
}
function broadcastToRoom(roomId: string, data: any, exclude?: WebSocket) {
const clients = rooms.get(roomId);
if (!clients) return;
const payload = JSON.stringify(data);
clients.forEach((client) => {
if (client !== exclude && client.readyState === WebSocket.OPEN) {
client.send(payload);
}
});
}
server.listen(8080);
Implementação do Cliente com Reconexão
class WebSocketClient {
private ws: WebSocket | null = null;
private url: string;
private reconnectAttempts = 0;
private maxReconnectAttempts = 10;
private handlers = new Map<string, Set<(data: any) => void>>();
constructor(url: string) {
this.url = url;
}
connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
this.reconnectAttempts = 0;
this.emit('connected', null);
};
this.ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
this.emit(message.type, message);
} catch (e) {
console.error('Failed to parse WebSocket message:', e);
}
};
this.ws.onclose = (event) => {
this.emit('disconnected', { code: event.code, reason: event.reason });
if (event.code !== 4001 && this.reconnectAttempts < this.maxReconnectAttempts) {
this.scheduleReconnect();
}
};
this.ws.onerror = () => {
// Error is followed by close event, handle reconnection there
};
}
private scheduleReconnect() {
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
this.reconnectAttempts++;
setTimeout(() => {
this.emit('reconnecting', { attempt: this.reconnectAttempts });
this.connect();
}, delay);
}
send(type: string, data: any) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type, ...data }));
}
}
on(event: string, handler: (data: any) => void) {
if (!this.handlers.has(event)) this.handlers.set(event, new Set());
this.handlers.get(event)!.add(handler);
return () => this.handlers.get(event)?.delete(handler);
}
private emit(event: string, data: any) {
this.handlers.get(event)?.forEach((handler) => handler(data));
}
disconnect() {
this.maxReconnectAttempts = 0; // Prevent reconnection
this.ws?.close();
}
}
Estratégia de Reconexão
A lógica de reconexão merece atenção porque afeta diretamente a experiência do usuário e a carga do servidor.
O backoff exponencial evita problemas de "thundering herd". Se o seu servidor reiniciar e 10.000 clientes se reconectarem simultaneamente, o servidor trava novamente. Ao adicionar jitter e atrasos exponenciais, as conexões se espalham ao longo do tempo:
function getReconnectDelay(attempt: number): number {
const baseDelay = 1000;
const maxDelay = 30000;
const exponentialDelay = baseDelay * Math.pow(2, attempt);
const jitter = Math.random() * 1000; // Random jitter up to 1 second
return Math.min(exponentialDelay + jitter, maxDelay);
}
A UI do estado da conexão é importante. Os usuários precisam saber quando estão desconectados e quando o aplicativo está tentando se reconectar. Um pequeno banner dizendo "Reconectando..." é melhor do que mostrar dados desatualizados silenciosamente.
Socket.IO: Conveniência vs. Controle
Socket.IO é a biblioteca WebSocket mais popular e oferece uma conveniência significativa: reconexão automática, gerenciamento de salas, confirmações e fallback para polling quando WebSockets não estão disponíveis. Também adiciona uma sobrecarga significativa e introduz uma camada de abstração que pode mascarar problemas.
Quando Usar Socket.IO
- Prototipagem rápida. A API do Socket.IO é mais simples do que a de WebSockets puros, e os recursos integrados economizam tempo de desenvolvimento.
- Quando você precisa de fallback de polling. Algumas redes corporativas bloqueiam conexões WebSocket. O Socket.IO automaticamente faz fallback para HTTP long polling.
- Gerenciamento de salas e namespaces. Se seu recurso em tempo real se mapeia naturalmente para salas (salas de chat, lobbies de jogos, documentos colaborativos), as abstrações de sala do Socket.IO economizam boilerplate.
Quando Evitar Socket.IO
- Aplicações de alto desempenho. O protocolo do Socket.IO adiciona sobrecarga a cada mensagem (enquadramento, codificação, metadados). Para aplicações de alto throughput, WebSockets puros são visivelmente mais rápidos.
- Quando você precisa de interoperabilidade. Socket.IO não é um WebSocket padrão — um cliente WebSocket regular não pode se conectar a um servidor Socket.IO. Se seus clientes incluem ambientes não-JavaScript (aplicativos móveis, dispositivos IoT), o Socket.IO requer uma biblioteca cliente para cada plataforma.
- Quando você quer entender o que está acontecendo. O Socket.IO abstrai o gerenciamento de conexão, o que é conveniente até que algo dê errado. Depurar um problema de conexão do Socket.IO geralmente significa entender a camada de abstração sobre o problema real.
Firebase Realtime Database e Firestore
O Firebase oferece recursos em tempo real sem gerenciar nenhuma infraestrutura WebSocket. Esta é sua principal proposta de valor — você troca controle por conveniência.
Firebase Realtime Database
import { getDatabase, ref, onValue, set } from 'firebase/database';
const db = getDatabase();
// Listen for real-time updates
const ordersRef = ref(db, `restaurants/${restaurantId}/orders`);
onValue(ordersRef, (snapshot) => {
const orders = snapshot.val();
updateUI(orders);
});
// Write data (triggers listeners on all connected clients)
await set(ref(db, `restaurants/${restaurantId}/orders/${orderId}`), {
status: 'preparing',
updatedAt: Date.now(),
});
O Realtime Database é uma árvore JSON. Cada escrita em qualquer nó se propaga imediatamente para cada cliente que está ouvindo naquele nó ou em qualquer nó pai. Isso é poderoso, mas perigoso — um listener em um nó de alto nível recebe atualizações para cada mudança em toda a subárvore.
Listeners em Tempo Real do Firestore
import { getFirestore, collection, onSnapshot, query, where } from 'firebase/firestore';
const db = getFirestore();
// Listen for active orders
const q = query(
collection(db, 'orders'),
where('restaurantId', '==', restaurantId),
where('status', 'in', ['pending', 'preparing', 'ready'])
);
const unsubscribe = onSnapshot(q, (snapshot) => {
snapshot.docChanges().forEach((change) => {
if (change.type === 'added') addOrder(change.doc.data());
if (change.type === 'modified') updateOrder(change.doc.data());
if (change.type === 'removed') removeOrder(change.doc.id);
});
});
Os listeners em tempo real do Firestore são mais granulares do que o Realtime Database — você pode ouvir consultas específicas, não apenas caminhos. O método docChanges() informa exatamente o que mudou, o que possibilita atualizações eficientes da UI.
Supabase Realtime
O Supabase oferece recursos em tempo real sobre o PostgreSQL, o que lhe dá o poder de consulta do SQL com atualizações em tempo real. Ele usa os recursos de replicação do PostgreSQL (replicação lógica e o WAL) para detectar mudanças e transmiti-las através de canais WebSocket.
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(url, key);
// Listen for changes to the orders table
const channel = supabase
.channel('orders')
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'orders',
filter: `restaurant_id=eq.${restaurantId}`,
},
(payload) => {
console.log('Change type:', payload.eventType);
console.log('New data:', payload.new);
console.log('Old data:', payload.old);
}
)
.subscribe();
A vantagem sobre o Firebase é clara: seus dados residem no PostgreSQL com recursos completos de consulta SQL, transações ACID e ferramentas padrão. A camada em tempo real é uma adição, não uma substituição para seu motor de consulta. A desvantagem é que o Supabase Realtime é menos testado em escala do que o Firebase, e o desempenho em tempo real depende do throughput de replicação do PostgreSQL.
Escalando Conexões WebSocket
Um único servidor pode lidar com dezenas de milhares de conexões WebSocket, mas escalar além de um servidor introduz um problema de coordenação: uma mensagem publicada no Servidor A precisa chegar aos clientes conectados ao Servidor B.
O Padrão Pub/Sub
Redis pub/sub é a solução padrão:
import { createClient } from 'redis';
const publisher = createClient();
const subscriber = createClient();
await publisher.connect();
await subscriber.connect();
// When a message comes in on any server, publish to Redis
function publishToRoom(roomId: string, message: any) {
publisher.publish(`room:${roomId}`, JSON.stringify(message));
}
// Each server subscribes and forwards to its local connections
await subscriber.subscribe(`room:${roomId}`, (message) => {
const data = JSON.parse(message);
broadcastToLocalClients(roomId, data);
});
Este padrão permite que você execute vários servidores WebSocket atrás de um balanceador de carga. Cada servidor lida com suas próprias conexões, e o Redis coordena a comunicação entre servidores.
Sticky Sessions
As conexões WebSocket são stateful — uma vez estabelecidas, elas devem permanecer no mesmo servidor. Os balanceadores de carga precisam de sticky sessions (também chamadas de afinidade de sessão) para rotear a conexão WebSocket de um cliente para o mesmo servidor que lidou com o upgrade HTTP inicial.
Com nginx:
upstream websocket_servers {
ip_hash; # Sticky sessions based on client IP
server ws1.example.com:8080;
server ws2.example.com:8080;
}
server {
location /ws {
proxy_pass http://websocket_servers;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 86400; # 24 hours
}
}
Tempo Real vs. Quase Tempo Real
Existe um espectro entre "verificar atualizações quando o usuário atualiza a página" e "entregar cada mudança em milissegundos". A maioria dos recursos se encaixa em algum lugar no meio.
| Abordagem | Latência | Complexidade | Melhor Para |
|---|---|---|---|
| Atualização de página | Iniciada pelo usuário | Nenhuma | Conteúdo estático, mudanças infrequentes |
| Polling (30s) | Até 30 segundos | Baixa | Contagens de notificações, atualizações de painel |
| Polling (5s) | Até 5 segundos | Baixa | Feeds de atividade, status de pedidos |
| Long polling | Sub-segundo | Moderada | Sistemas mais antigos, ambientes hostis a proxy |
| SSE | Sub-segundo | Moderada | Feeds ao vivo, notificações, fluxos unidirecionais |
| WebSockets | Sub-segundo | Alta | Chat, colaboração, tempo real bidirecional |
| WebRTC | Milissegundos | Muito alta | Vídeo/áudio, peer-to-peer |
A abordagem correta é a mais simples que atende ao seu requisito de latência. Comece com polling. Se os usuários reclamarem de dados desatualizados, atualize para SSE. Se você precisar de comunicação bidirecional, use WebSockets. Cada passo na escada da complexidade deve ser justificado por uma necessidade concreta do usuário, não por um requisito futuro hipotético.
Recomendações Práticas
Depois de construir recursos em tempo real em vários projetos, estes são os padrões aos quais sempre volto:
Padrão para SSE para tempo real unidirecional. A maioria dos recursos em tempo real são do servidor para o cliente: atualizações de pedidos, fluxos de notificação, feeds de dados ao vivo. SSE lida com tudo isso com menos complexidade do que WebSockets.
Use WebSockets apenas para necessidades bidirecionais. Chat, edição colaborativa, interações multiplayer — estes realmente precisam de WebSockets. Se seu cliente apenas recebe dados, SSE é mais simples.
Sempre implemente reconexão com backoff. As conexões caem. As redes mudam. Os servidores reiniciam. Seu cliente precisa lidar com isso graciosamente, e seu servidor precisa sobreviver a uma tempestade de reconexão.
Envie diffs, não o estado completo. Em vez de enviar a lista completa de pedidos toda vez que um pedido muda, envie apenas o pedido alterado. O cliente aplica o diff ao seu estado local. Isso reduz a largura de banda e o processamento em ambos os lados.
Tenha um fallback. Se sua conexão WebSocket falhar e não conseguir se reconectar, o aplicativo ainda deve ser funcional. Um indicador "última atualização há 30 segundos" com atualização manual é melhor do que uma tela em branco.
Monitore as contagens de conexão em produção. As conexões WebSocket consomem recursos do servidor (memória, descritores de arquivo). Um pico repentino de conexões — de um bug do cliente causando reconexões rápidas, por exemplo — pode derrubar seu servidor. Alerte sobre anomalias na contagem de conexões.
O objetivo não é ter a arquitetura de tempo real mais sofisticada. O objetivo é entregar dados aos usuários rápido o suficiente para que o aplicativo pareça responsivo, com a menor quantidade de complexidade de infraestrutura que atinja essa sensação.