Streaming SSR em Nuxt 3: server routes Nitro com SSE e NDJSON e consumo no cliente
Por John Pires · 14 de março de 2026

Cenario pratico do dia a dia
O problema que aparece no projeto
Equipes que constroem dashboards, logs em tempo real ou páginas que precisam montar o HTML progressivamente enfrentam dois problemas recorrentes: latência no primeiro paint e uso excessivo de memória no servidor. O padrão de chamar uma API que só retorna quando o processamento termina — e então enviar tudo junto — é o caso mais comum e geralmente ineficiente.
Quando usar streaming vs polling
Polling é simples, mas gera latência e overhead. Streaming (SSE/NDJSON) mantém uma conexão e empurra eventos conforme ficam prontos. Para atualizações unidirecionais e simples (logs, progresso, resultados incrementais), SSE/NDJSON é uma boa escolha. Para comunicação bidirecional complexa, considere WebSocket. Aqui focamos em como expor streams com Nitro (server routes) e como consumir no cliente Nuxt 3, inclusive em contexto SSR.
Resumo do cenário: backend Node/Nitro roda endpoints que produzem dados incrementalmente; Nuxt 3 precisa renderizar a página e hidratar componentes que recebem updates em tempo real, sem bloquear SSR por operações longas.
Arquitetura da solucao
Componentes envolvidos
Arquitetura simples e prática para este padrão:
- Server route Nitro que expõe um stream (SSE ou NDJSON).
- Consumer no cliente: EventSource ou fetch streaming com ReadableStream para NDJSON.
- Composable Nuxt para encapsular lógica de consumo e reconexão.
- Renderização SSR que não espera todo o stream: a resposta HTML é entregue enquanto o stream de dados segue separado via endpoint.
Decisões de design
Escolhas técnicas e trade-offs:
- Usar SSE (text/event-stream) quando precisar de reconexão automática simples no browser.
- Usar NDJSON (application/x-ndjson) quando precisar de parsers de JSON linha-a-linha e controle fino do transporte (mais eficiente para parse em streams binários).
- Evitar long-polling no SSR: não bloquear o render do HTML enquanto espera o stream; em vez disso, renderizar o shell e deixar o cliente anexar o stream assíncrono.
Dica: SSE é ótimo para navegadores, mas em ambientes server-side (por exemplo outra aplicação consumindo seu serviço) NDJSON pode ser preferível por ser mais agnóstico ao cliente.
Codigo completo com explicacoes
Exemplo errado: bloco que coleta tudo antes de responder
Erro comum: implementar um endpoint que espera todo processamento (logs, resultados) antes de retornar JSON. Isso gera alta latência e memória.
export default defineEventHandler(async (event) => {
// Faz processamento pesado e acumula strings -> memória cresce
const results = []
for (let i = 0; i < 1000; i++) {
const item = await slowOp(i) // supõe operação demorada
results.push(item)
}
return { results }
})
O que esse código faz: acumula tudo em memory e retorna apenas quando tudo termina. O que acontece: bloqueio de resposta, alta latência e possível OOM em carga. Não faça isso quando os dados podem ser produzidos incrementalmente.
Server route correta: SSE com Nitro
Abaixo um exemplo funcional de rota Nitro que emite eventos SSE. Útil para logs/progress. O código abre uma resposta e envia linhas formatadas conforme o RFC de SSE.
import { H3Event } from 'h3'
export default defineEventHandler(async (event: H3Event) => {
const res = event.node.res
// Cabeçalhos obrigatórios para SSE
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
res.setHeader('Connection', 'keep-alive')
res.setHeader('X-Accel-Buffering', 'no') // para nginx
// Função auxiliar para enviar um evento SSE
const send = (data: string, eventName?: string) => {
if (eventName) res.write(`event: ${eventName}\n`)
res.write(`data: ${data}\n\n`)
}
// Envia um 'open' inicial
send(JSON.stringify({ status: 'connected' }), 'meta')
// Simula emissões periódicas
let i = 0
const interval = setInterval(() => {
i++
send(JSON.stringify({ tick: i }))
if (i >= 10) {
send(JSON.stringify({ status: 'end' }), 'meta')
clearInterval(interval)
res.end()
}
}, 300)
// Garantir que a rota não feche o handler imediatamente
await new Promise<void>((resolve) => event.node.res.on('close', resolve))
})
O que esse código faz: inicia uma resposta SSE e emite eventos a cada 300ms. O que acontece: o cliente que usar EventSource receberá mensagens incrementalmente; o servidor não acumula dados em memória.
Server route alternativa: NDJSON (linha por linha JSON)
NDJSON é preferível quando você quer transmitir JSON puro, cada linha é um objeto, e o consumidor lê linha por linha. Exemplo abaixo de rota Nitro que envia NDJSON.
export default defineEventHandler(async (event) => {
const res = event.node.res
res.setHeader('Content-Type', 'application/x-ndjson')
res.setHeader('Cache-Control', 'no-cache')
res.setHeader('Connection', 'keep-alive')
res.setHeader('X-Accel-Buffering', 'no')
// Simula stream de objetos JSON
for (let i = 1; i <= 20; i++) {
const obj = { id: i, value: `value-${i}` }
res.write(JSON.stringify(obj) + '\n')
await new Promise((r) => setTimeout(r, 100))
}
res.end()
})
O que esse código faz: envia 20 objetos JSON, uma linha por vez. O que acontece: consumidores que leem por linha conseguem processar cada objeto imediatamente.
Tratamento de erros e edge cases
Conexões que caem e reconexão
Em SSE o browser gerencia reconexão automática, mas você precisa enviar 'id' em eventos para permitir reconexões seguras e talvez recomeçar do último id processado. Em NDJSON, se a conexão cair, o cliente precisa reestabelecer e potencialmente pedir ao servidor um range/offset.
Timeouts, proxies e buffering
Proxies intermediários (nginx, Varnish) costumam bufferizar por padrão e quebram streaming. Configure headers como X-Accel-Buffering: no para nginx (ou desative buffering), garanta que o reverse proxy suporte transfer-encoding chunked, e utilize pequenos flushes (res.write) para manter fluxo. Em plataformas serverless você pode estar limitado: algumas providers não suportam streaming de forma eficiente.
Erro comum: não definir Connection: keep-alive ou deixar o proxy bufferizar o corpo. Efeito: o cliente só verá tudo no final, perdendo o propósito do streaming.
Exemplo de consumo NDJSON no cliente (correto)
Consumir NDJSON no cliente com fetch e stream parsing via ReadableStream. O composable abaixo é um padrão para Nuxt 3 que encapsula a lógica de parsing linha-a-linha e reconexão opcional.
import { ref, onUnmounted } from 'vue'
export function useNdjsonStream(url) {
const items = ref([])
let controller = null
const start = async () => {
controller = new AbortController()
const res = await fetch(url, { signal: controller.signal })
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buf = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buf += decoder.decode(value, { stream: true })
let lines = buf.split('\n')
buf = lines.pop() // última linha possivelmente incompleta
for (const line of lines) {
if (!line.trim()) continue
try {
items.value.push(JSON.parse(line))
} catch (e) {
console.error('ndjson parse error', e, line)
}
}
}
}
const stop = () => controller?.abort()
onUnmounted(stop)
return { items, start, stop }
}
O que esse código faz: abre fetch, lê chunks binários, decodifica texto e separa por linhas, parseia JSON e empurra para o array reativo. O que acontece: o componente que usa esse composable verá items crescer conforme chegam linhas.
Consumidor SSE: EventSource + fallback
EventSource é trivial no browser, mas não funciona em SSR (Node). Para SSR você não consome streams; você apenas valida que o shell foi renderizado e o cliente irá conectar. Se precisar consumir do servidor (por exemplo SSR que precisa de dados iniciais por stream), prefira NDJSON com fetch streaming no server (se a plataforma suportar).
// Composable simples para SSE (client-only)
import { ref, onUnmounted } from 'vue'
export function useSse(url) {
const events = ref([])
let es = null
const start = () => {
if (typeof window === 'undefined') return
es = new EventSource(url)
es.onmessage = (ev) => events.value.push(JSON.parse(ev.data))
es.onerror = (err) => console.error('SSE error', err)
}
const stop = () => es?.close()
onUnmounted(stop)
return { events, start, stop }
}
O que esse código faz: cria um EventSource no cliente e empurra mensagens para um reactive. O que acontece: reconexão automática é gerenciada pelo browser. Não use EventSource em SSR — rode esse composable apenas em mounted ou client-only.
Performance e otimizacao
Minimizar overhead no servidor
Evite construir strings grandes no servidor. Use streams e flushes regulares (res.write) para manter uso de memória baixo. Em embarque com Node, evite JSON.stringify de coleções inteiras; serialize apenas o que precisa ser enviado naquele momento.
Batching e backpressure
Envie pacotes de dados em blocos sensatos. Para fontes muito rápidas, agrupe N eventos antes de enviar para reduzir overhead de I/O. Para controlar backpressure no fetch streaming, verifique reader.desiredSize e implemente lógica para pausar produção se necessário. Nitro não expõe flow control de alto nível; se você precisa de backpressure robusto, migre para streams Node.js ou worker que suportem APIs de stream.
Armadilha comum: enviar um evento por milissegundo gera overhead excessivo no TCP e no proxy; agrupe ou aplique debounce no lado do produtor.
SSR e render streaming
Nuxt 3 roda renderização no servidor via Nitro. Por padrão, o SSR aguarda o template HTML pronto. Para reduzir TTFB, faça streaming do HTML (se a plataforma permitir) ou entregue o shell estático e deixe o cliente buscar dados via SSE/NDJSON. Em muitos cenários, renderizar o layout e placeholders no servidor é suficiente para uma boa UX.
Erros comuns, armadilhas e dicas praticas
Buffering do proxy
Problema: nginx e outros proxys bufferizam por padrão. Sintomas: o cliente só recebe tudo no final. Solução: desabilitar buffering com X-Accel-Buffering: no, configurar proxy_buffering off; para Cloud providers, revise docs de streaming.
Uso indevido de EventSource no SSR
Erro: tentar instanciar EventSource no server. Resultado: crashes silenciosos. Sempre rode a lógica de EventSource apenas no cliente, por exemplo dentro de onMounted ou usando client-only em componentes Nuxt.
Serializacao e delimitadores
Não confie em JSON.stringify único quando usar NDJSON — use \n como delimitador. Em SSE, escape quebras de linha em 'data:' ou envie JSON em uma única linha. Em ambos os formatos, verifique compatibilidade com consumidores não-browser.
Tip de produção: se você precisa que o consumidor retome do ponto onde parou, envie along stream um campo 'cursor' ou 'id' e implemente endpoints que aceitem 'Last-Event-ID' (SSE) ou '?since=cursor' (NDJSON).
Conclusao pratica
Streaming com Nitro em Nuxt 3 resolve latência e memória quando usado corretamente. Evite o antipato de acumular dados antes de responder; prefira SSE para integraoes simples no browser e NDJSON quando precisar de parsing linha-a-linha e controle mais fino. Proteja-se contra proxies que bufferizam e implemente reconexes e cursors para robustez.
Implementar um composable para o cliente e manter o servidor simples (res.write + flush) garante que o sistema seja escalvel e previsvel. Teste em ambiente que reflita a infra real (nginx, cloud provider) e meça latncia, throughput e uso de memoria.
Para projetos Nuxt 3 com atualizaes em tempo real, o fluxo recomendado: renderizar shell no SSR, conectar o cliente a SSE/NDJSON com um composable bem testado e lidar com reconexes e erros de forma explícita. Essa abordagem reduz TTFB, melhora UX e torna a aplicao mais resiliente.
Checklist final rápido:
- Server route: definir headers corretos e evitar buffer global
- Client: usar EventSource para SSE ou fetch streaming para NDJSON
- Proxies: desabilitar buffering e garantir chunked transfer
- Resilincia: enviar id/cursor e suportar reconexes
Com isso, você tem os padres praticos para migrar endpoints sincronizados e ineficientes para streams reativos, mantendo a renderizao SSR leve e a experincia do usuario responsiva.
Assuntos: Nuxt.js, nitro, sse, ndjson, streaming, ssr
Leia também
Tabela paginada reativa em Vue 3: Composition API e Pinia passo a passo
Implementação prática de uma tabela paginada reativa em Vue 3 usando Composition API e Pinia. Guia passo a passo com store, componente, mock de API, erros…
useServerQuery no Nuxt 3: composable SSR que deduplica, cache e revalida
Composble server-only para Nuxt 3: deduplicação de requisições, cache por processo e stale-while-revalidate prático. Código real, testes e armadilhas para…
Virtualização reativa em Vue 3: Composition API + Pinia para listas gigantes
Prático e direto: como substituir o anti-pattern de renderizar tudo por uma virtualização reativa em Vue 3 usando Composition API e Pinia, com padrões…
Autenticação SSR em Nuxt 3 com refresh tokens rotativos e server routes
Guia prático para implementar autenticação SSR em Nuxt 3 usando refresh tokens rotativos, cookies httpOnly e server routes. Padrões, code samples e armadilhas…
Implementando ISR em Nuxt 3 (Nitro): gerar, invalidar e cache por rota
Padrão prático de ISR em Nuxt 3 (Nitro): cache por rota de dados, stale-while-revalidate, revalidação em background e endpoint seguro de invalidação. Código…
Hydration parcial no Vue 3: otimizar SSR com Pinia, Suspense e lazy components
Padrão prático de partial hydration em Vue 3: como transferir estado do Pinia, montar islands com Suspense e lazy components, e reduzir TTI sem hidratar a…