Tempo real (WebSocket e SSE)
Formas de o servidor enviar dados ao cliente sem esperar um pedido, e o custo de manter conexões abertas.
Os servidores desta página foram executados e testados com curl, com Chrome e atrás de um Nginx.
Um servidor SSE, em cada linguagem
Cada versão envia um evento numerado a cada 300 ms, um pulso a cada 15 segundos e continua a partir do Last-Event-ID, quando o cliente o informa.
import http from "node:http";
http.createServer((req, res) => {
if (req.url !== "/eventos") {
res.statusCode = 404;
return res.end();
}
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no", // pede ao Nginx que não acumule a resposta
});
res.write("retry: 1000\n\n"); // o navegador reconecta após 1 s se a conexão cair
// Continua de onde o cliente parou, se ele informar o último evento recebido
let ultimo = Number(req.headers["last-event-id"] ?? 0);
const eventos = setInterval(() => {
ultimo++;
res.write(`id: ${ultimo}\nevent: progresso\ndata: ${JSON.stringify({ pedido: 981, etapa: ultimo })}\n\n`);
}, 300);
const pulso = setInterval(() => res.write(": ping\n\n"), 15_000); // mantém a conexão viva
req.on("close", () => {
clearInterval(eventos);
clearInterval(pulso);
});
}).listen(3000);import asyncio
import json
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
app = FastAPI()
@app.get("/eventos")
async def eventos(request: Request):
# Continua de onde o cliente parou, se ele informar o último evento recebido
ultimo = int(request.headers.get("last-event-id", 0))
async def fluxo():
nonlocal ultimo
yield "retry: 1000\n\n" # o navegador reconecta após 1 s se a conexão cair
desde_o_pulso = 0.0
while not await request.is_disconnected():
ultimo += 1
dados = json.dumps({"pedido": 981, "etapa": ultimo})
yield f"id: {ultimo}\nevent: progresso\ndata: {dados}\n\n"
await asyncio.sleep(0.3)
desde_o_pulso += 0.3
if desde_o_pulso >= 15:
yield ": ping\n\n" # mantém a conexão viva
desde_o_pulso = 0
return StreamingResponse(
fluxo(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)package main
import (
"fmt"
"net/http"
"strconv"
"time"
)
func eventos(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming não suportado", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("X-Accel-Buffering", "no")
fmt.Fprint(w, "retry: 1000\n\n")
flusher.Flush()
// Continua de onde o cliente parou, se ele informar o último evento recebido
ultimo, _ := strconv.Atoi(r.Header.Get("Last-Event-ID"))
eventos := time.NewTicker(300 * time.Millisecond)
pulso := time.NewTicker(15 * time.Second)
defer eventos.Stop()
defer pulso.Stop()
for {
select {
case <-r.Context().Done(): // o cliente desconectou
return
case <-eventos.C:
ultimo++
fmt.Fprintf(w, "id: %d\nevent: progresso\ndata: {\"pedido\":981,\"etapa\":%d}\n\n", ultimo, ultimo)
flusher.Flush()
case <-pulso.C:
fmt.Fprint(w, ": ping\n\n") // mantém a conexão viva
flusher.Flush()
}
}
}
func main() {
http.HandleFunc("GET /eventos", eventos)
http.ListenAndServe(":3000", nil)
}import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.Executors;
public class Eventos {
public static void main(String[] args) throws IOException {
var servidor = HttpServer.create(new InetSocketAddress(3000), 0);
servidor.setExecutor(Executors.newVirtualThreadPerTaskExecutor()); // uma thread leve por conexão
servidor.createContext("/eventos", ex -> {
ex.getResponseHeaders().set("Content-Type", "text/event-stream");
ex.getResponseHeaders().set("Cache-Control", "no-cache");
ex.getResponseHeaders().set("X-Accel-Buffering", "no");
ex.sendResponseHeaders(200, 0); // 0: tamanho desconhecido, envio em partes
// Continua de onde o cliente parou, se ele informar o último evento recebido
var cabecalho = ex.getRequestHeaders().getFirst("Last-Event-ID");
int ultimo = cabecalho == null ? 0 : Integer.parseInt(cabecalho);
try (var saida = ex.getResponseBody()) {
saida.write("retry: 1000\n\n".getBytes(StandardCharsets.UTF_8));
saida.flush();
long ultimoPulso = System.currentTimeMillis();
while (true) {
ultimo++;
var evento = "id: " + ultimo + "\nevent: progresso\ndata: {\"pedido\":981,\"etapa\":"
+ ultimo + "}\n\n";
saida.write(evento.getBytes(StandardCharsets.UTF_8));
saida.flush();
Thread.sleep(300);
if (System.currentTimeMillis() - ultimoPulso >= 15_000) {
saida.write(": ping\n\n".getBytes(StandardCharsets.UTF_8)); // mantém a conexão viva
ultimoPulso = System.currentTimeMillis();
}
}
} catch (IOException | InterruptedException desconectou) {
// o cliente fechou a conexão
}
});
servidor.start();
}
}var app = WebApplication.Create(args);
app.MapGet("/eventos", async (HttpContext contexto, CancellationToken cancelamento) =>
{
var resposta = contexto.Response;
resposta.Headers.ContentType = "text/event-stream";
resposta.Headers.CacheControl = "no-cache";
resposta.Headers["X-Accel-Buffering"] = "no";
// Continua de onde o cliente parou, se ele informar o último evento recebido
int.TryParse(contexto.Request.Headers["Last-Event-ID"], out var ultimo);
try
{
await resposta.WriteAsync("retry: 1000\n\n", cancelamento);
await resposta.Body.FlushAsync(cancelamento);
var ultimoPulso = DateTime.UtcNow;
while (!cancelamento.IsCancellationRequested)
{
ultimo++;
await resposta.WriteAsync(
$"id: {ultimo}\nevent: progresso\ndata: {{\"pedido\":981,\"etapa\":{ultimo}}}\n\n", cancelamento);
await resposta.Body.FlushAsync(cancelamento);
await Task.Delay(300, cancelamento);
if (DateTime.UtcNow - ultimoPulso >= TimeSpan.FromSeconds(15))
{
await resposta.WriteAsync(": ping\n\n", cancelamento); // mantém a conexão viva
ultimoPulso = DateTime.UtcNow;
}
}
}
catch (OperationCanceledException)
{
// o cliente fechou a conexão
}
});
app.Run("http://0.0.0.0:3000");O resultado, nas cinco linguagens
Cada servidor recebeu dois pedidos com curl -N: um sem cabeçalho, por cerca de 1,6 segundo, e outro com Last-Event-ID: 41, por cerca de 1 segundo.
Sem Last-Event-ID | Com Last-Event-ID: 41 | |
|---|---|---|
| TypeScript | eventos 1 a 5 | eventos 42 a 44 |
| Python (FastAPI) | eventos 1 a 6 | eventos 42 a 45 |
| Go | eventos 1 a 5 | eventos 42 a 44 |
| Java | eventos 1 a 6 | eventos 42 a 45 |
| C# (ASP.NET) | eventos 1 a 6 | eventos 42 a 45 |
A diferença de um evento entre as versões vem só do momento em que cada uma envia o primeiro: algumas escrevem antes de esperar os 300 ms, outras depois. O comportamento que importa, continuar a partir do evento informado, foi o mesmo nas cinco.
O que muda de uma linguagem para outra
| Como enviar cada evento na hora | Como saber que o cliente saiu | |
|---|---|---|
| TypeScript | res.write, que envia sem esperar o fim | Evento close do pedido |
| Python | Um gerador assíncrono no StreamingResponse | request.is_disconnected() |
| Go | http.Flusher e Flush() depois de cada escrita | r.Context().Done() |
| Java | sendResponseHeaders(200, 0) e flush() | IOException ao escrever |
| C# | Response.Body.FlushAsync() depois de cada escrita | O CancellationToken da rota |
Sem o envio imediato (Flush, flush ou o equivalente), os eventos ficam no buffer do próprio servidor e só saem em blocos, o mesmo sintoma do proxy com compressão. O .NET 10 acrescenta um tipo de resultado pronto para SSE (TypedResults.ServerSentEvents), e o Spring tem o SseEmitter; os exemplos usam a forma manual para mostrar o protocolo.
O exemplo gera os eventos a partir de um contador. Num sistema real, eles viriam de uma assinatura no Redis ou de uma fila, e a retomada buscaria os eventos guardados depois do Last-Event-ID, ou mandaria o estado atual.
O cliente no navegador
const fonte = new EventSource("/eventos"); // envia os cookies do mesmo domínio
fonte.addEventListener("progresso", (evento) => {
const dados = JSON.parse(evento.data);
atualizarTela(dados);
});
fonte.onerror = () => {
// O navegador já está reconectando sozinho, com Last-Event-ID.
// Só feche se o servidor disser que não há mais nada a enviar.
};No teste com o Chrome, esse cliente sobreviveu à queda e à volta do servidor sem nenhuma linha de reconexão: o navegador reconectou depois do intervalo definido em retry: 1000, enviou Last-Event-ID: 5 e recebeu os eventos de 6 a 11.
Atrás de um Nginx
location /eventos {
proxy_pass http://app;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off; # ou o cabeçalho X-Accel-Buffering: no na resposta
proxy_read_timeout 1h; # maior que o intervalo dos pulsos
gzip off;
}No teste, com a compressão ligada para text/event-stream e sem o cabeçalho na resposta, o cliente não recebeu nenhum evento em 3 segundos. Com X-Accel-Buffering: no enviado pela aplicação, recebeu os 9. A configuração acima deixa a rota de eventos segura, mesmo que alguém ligue a compressão em outro lugar.
Um servidor WebSocket com pulsos
import { WebSocketServer } from "ws";
const servidor = new WebSocketServer({ port: 3000 });
servidor.on("connection", (socket) => {
socket.vivo = true;
socket.on("pong", () => (socket.vivo = true));
socket.on("message", (bruto) => {
const mensagem = JSON.parse(bruto.toString());
if (mensagem.tipo === "digitando") {
// repassa a todos os outros clientes conectados a esta instância
for (const outro of servidor.clients) {
if (outro !== socket && outro.readyState === outro.OPEN) {
outro.send(JSON.stringify({ tipo: "digitando", usuario: mensagem.usuario }));
}
}
}
});
});
// Ping periódico: derruba conexões que pararam de responder
setInterval(() => {
for (const socket of servidor.clients) {
if (!socket.vivo) socket.terminate();
socket.vivo = false;
socket.ping();
}
}, 30_000);Testamos com dois clientes: a mensagem de "digitando" enviada pelo primeiro chegou ao segundo. O laço de ping resolve as conexões mortas sem aviso: quem não responder ao ping até a rodada seguinte é encerrado, liberando memória e descritores.
Este servidor repassa só aos clientes da mesma instância. Com várias cópias, a mensagem precisa passar por um canal de publicação e assinatura, como descreve o nível Soluções. No WebSocket, confira também o cabeçalho Origin na abertura da conexão (a opção verifyClient da biblioteca ws), já que o navegador envia os cookies do usuário para qualquer origem.
Lista de revisão
Escolha
- A forma combina com a necessidade: SSE para avisos do servidor, WebSocket para troca nos dois sentidos?
- Existe uma rota comum com o estado atual, como reserva?
Conexão
- Há pulsos com intervalo menor que o tempo de ociosidade do balanceador?
- Nenhum buffer ou compressão segura os eventos no caminho?
- Conexões mortas são detectadas e encerradas?
- O servidor envia cada evento na hora, com o
flushadequado?
Escala e confiabilidade
- Os eventos chegam aos clientes conectados em outras instâncias?
- A reconexão retoma de onde parou, ou recebe o estado atual?
- Os limites de descritores e de memória comportam todas as conexões?
- Os clientes reconectam com variação aleatória, para um deploy não gerar uma onda?
Segurança
- A conexão é autenticada na abertura e encerrada quando a credencial vence?
- O WebSocket confere o
Origin? - Há limites de conexões por usuário e de mensagens por segundo?
Prompts para aprofundar
Ao abrir no ChatGPT ou no Claude, o prompt é enviado ao serviço escolhido pela URL e também fica copiado na sua área de transferência.
Implementar SSE com retomada
Quero implementar Server-Sent Events na minha aplicação, com retomada depois de uma reconexão. Pergunte qual linguagem e framework eu uso. Vamos em etapas: 1. Uma rota que envia eventos numerados, com envio imediato de cada um. 2. O tratamento do cabeçalho Last-Event-ID para continuar de onde o cliente parou. 3. Pulsos periódicos e a detecção do cliente que saiu. 4. Um cliente no navegador com EventSource. 5. Um teste derrubando o servidor e confirmando que o navegador reconecta e retoma. 6. A configuração do proxy reverso para não acumular os eventos. Em cada etapa, me passe os comandos para testar com curl ou com o navegador.
Implementar um WebSocket robusto
Quero implementar um servidor WebSocket pronto para produção. Pergunte qual linguagem e biblioteca eu uso. Explique, com código: 1. A autenticação na abertura e a conferência do Origin. 2. O formato das mensagens e a validação de cada uma. 3. Pulsos com ping e pong, e o encerramento das conexões mortas. 4. A reconexão no cliente, com intervalo crescente e variação aleatória. 5. A publicação entre instâncias com Redis. 6. Limites de mensagens por segundo e de tamanho de mensagem.
Revisar uma implementação de tempo real
Vou colar abaixo o código de uma funcionalidade de tempo real (SSE ou WebSocket), do servidor e do cliente, e a configuração do proxy, se houver. Revise para produção. Verifique, e para cada item diga se está correto, incorreto ou ausente, citando o trecho: 1. A forma escolhida combina com a necessidade? 2. Há pulsos e detecção de conexões mortas? 3. Os eventos são enviados na hora, sem buffer no servidor ou no proxy? 4. A reconexão retoma de onde parou ou recebe o estado atual? 5. Os eventos chegam aos clientes de outras instâncias? 6. A autenticação acontece na abertura? O WebSocket confere o Origin? 7. Há limites de conexões e de mensagens? Para cada problema, descreva como ele aparece em produção e mostre a correção. Código e configuração: [cole aqui]