Destino Dev

Blog

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:

  1. Usar SSE (text/event-stream) quando precisar de reconexão automática simples no browser.
  2. 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).
  3. 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