Streaming e Suspense: eliminando o waterfall que trava a página
Um await depois do outro num Server Component soma latências. Como paralelizar e, melhor ainda, entregar a página antes dos dados chegarem.
Ambiente testado
- Next.js 15.3
- React 19.1
- Vercel · Node 22
Contexto: o que estava rodando
Um painel com quatro blocos: resumo de faturamento, últimos pedidos, alertas de estoque e um ranking de produtos. Cada bloco vem de uma origem diferente — três consultas ao banco e uma chamada a um serviço externo.
O componente da página buscava tudo e depois renderizava.
O sintoma
GET /painel
Waiting for server response (TTFB) .... 3.21 s
Content Download ....................... 0.04 sTrês segundos de tela em branco. Não havia JavaScript pesado, não havia problema de fonte — o servidor simplesmente não mandava byte nenhum enquanto os dados não chegassem.
Diagnóstico
O código que soma latências
export default async function Painel() {
const faturamento = await buscarFaturamento(); // 780 ms
const pedidos = await buscarUltimosPedidos(); // 420 ms
const estoque = await buscarAlertasEstoque(); // 310 ms
const ranking = await buscarRanking(); // 1690 ms — serviço externo
return (
<main>
<Faturamento dados={faturamento} />
<Pedidos dados={pedidos} />
<Estoque dados={estoque} />
<Ranking dados={ranking} />
</main>
);
}Quatro await em sequência. Cada um espera o anterior terminar, mesmo sem
depender dele. O total é a soma: 780 + 420 + 310 + 1690 = 3.200 ms.
Isso é um waterfall. O nome vem do formato que a aba Network desenha: degraus descendo, cada requisição começando onde a anterior acabou.
Promise.all conserta metade
const [faturamento, pedidos, estoque, ranking] = await Promise.all([
buscarFaturamento(),
buscarUltimosPedidos(),
buscarAlertasEstoque(),
buscarRanking(),
]);
Agora o total é o maior, não a soma: 1.690 ms. Melhora de quase metade, e custa uma linha.
Mas o TTFB continua sendo 1.690 ms, porque a página ainda espera todos os dados antes de mandar qualquer coisa. O bloco de faturamento, pronto em 780 ms, fica esperando o ranking do serviço externo.
O que o streaming permite
O HTTP permite mandar a resposta em pedaços. O React sabe renderizar o que já tem, deixar um espaço reservado para o que falta, e continuar mandando conforme os dados chegam — na mesma resposta, sem requisição nova.
O usuário recebe cabeçalho, menu e estrutura em milissegundos, com marcadores de carregamento onde os dados ainda não chegaram. Cada bloco aparece quando fica pronto, na ordem em que ficar.

A solução
-
Isolar cada bloco no próprio componente assíncrono.
A busca desce para dentro do componente que usa o dado. Isso é o oposto do conselho tradicional de “buscar tudo no topo”, e é o que torna o streaming possível.
src/app/painel/blocos/Ranking.tsxexport async function Ranking() { const dados = await buscarRanking(); return <TabelaRanking dados={dados} />; } -
Envolver cada um em
Suspense.Cada fronteira de
Suspenseé um ponto onde o React pode cortar a resposta: ele manda ofallbackagora e o conteúdo real depois.src/app/painel/page.tsximport { Suspense } from 'react'; import { Faturamento, Pedidos, Estoque, Ranking } from './blocos'; import { Esqueleto } from '@/components/Esqueleto'; // Sem await aqui: a função nem precisa ser assíncrona. export default function Painel() { return ( <main> <h1>Painel</h1> <Suspense fallback={<Esqueleto altura={120} />}> <Faturamento /> </Suspense> <Suspense fallback={<Esqueleto altura={320} />}> <Pedidos /> </Suspense> <Suspense fallback={<Esqueleto altura={200} />}> <Estoque /> </Suspense> {/* O mais lento não segura mais ninguém. */} <Suspense fallback={<Esqueleto altura={280} />}> <Ranking /> </Suspense> </main> ); }As quatro buscas partem juntas — o React inicia todos os filhos — e cada uma entrega quando termina.
-
Reservar o espaço certo no
fallback.Um esqueleto de altura diferente da do conteúdo final empurra a página quando o dado chega. Streaming trocaria TTFB por CLS, o que não é ganho:
src/components/Esqueleto.tsxexport function Esqueleto({ altura }: { altura: number }) { return ( <div // A altura precisa ser a do conteúdo real, não uma aproximação. style={{ height: altura }} className="animate-pulse rounded-lg bg-neutral-100" aria-hidden /> ); } -
Passar a promessa adiante quando o dado é compartilhado.
Quando dois blocos usam a mesma consulta, buscar duas vezes é desperdício e buscar no topo com
awaittraz o waterfall de volta. A saída é começar a busca sem esperar e deixar cada filho consumir comuse():src/app/painel/page.tsxexport default function Painel() { // Sem await: dispara agora, entrega a promessa. const usuarioPromise = buscarUsuario(); return ( <> <Suspense fallback={<Esqueleto altura={64} />}> <Cabecalho usuarioPromise={usuarioPromise} /> </Suspense> <Suspense fallback={<Esqueleto altura={200} />}> <Preferencias usuarioPromise={usuarioPromise} /> </Suspense> </> ); }src/app/painel/blocos/Cabecalho.tsximport { use } from 'react'; export function Cabecalho({ usuarioPromise }: { usuarioPromise: Promise<Usuario> }) { const usuario = use(usuarioPromise); return <h2>Olá, {usuario.nome}</h2>; } -
Cuidar do
layout.tsx, que bloqueia tudo.Um
awaitno layout segura todas as páginas abaixo dele, e nenhuma fronteira deSuspenseinterna resolve isso. Se o layout precisa de dado, ele também precisa deSuspense— em volta da parte que depende, nunca em volta dochildren. -
Usar
loading.tsxpara a navegação entre rotas.Ele é um
Suspenseno nível da rota inteira, aplicado durante a transição:src/app/painel/loading.tsximport { Esqueleto } from '@/components/Esqueleto'; export default function Carregando() { return <Esqueleto altura={800} />; }Ele não substitui as fronteiras internas — resolve o clique no menu, não o carregamento inicial dos blocos.
Como confirmar que resolveu
O TTFB despenca:
GET /painel
Waiting for server response (TTFB) .... 0.09 s
Content Download ....................... 1.71 s ← chegando em pedaçosO tempo total não mudou muito — o serviço externo continua levando 1,7 s. O que mudou é que o usuário vê a página em 90 ms em vez de 3,2 s.
Veja os pedaços chegando. curl sem buffer mostra a resposta sendo montada:
curl -N --raw https://seu-app.vercel.app/painel | head -c 2000
Se todo o HTML aparecer de uma vez, o streaming não está acontecendo — quase
sempre por causa de um await no layout ou na página.
Confirme que as buscas são paralelas. Cada bloco pode registrar seu próprio tempo:
console.time('ranking');
const dados = await buscarRanking();
console.timeEnd('ranking');
Nos logs do servidor, os quatro blocos devem começar no mesmo instante.
Meça o CLS. Streaming introduz conteúdo depois da primeira pintura, que é exatamente a condição para deslocamento de layout. Com throttling Slow 3G, nada pode se mover quando cada bloco chega.
Armadilhas que sobram depois disso
Página estática não faz streaming. Se a rota for pré-renderizada em build, o HTML já está pronto e não há o que transmitir. Streaming vale para renderização dinâmica — o que também significa que cada acesso custa processamento.
Suspense demais fragmenta a experiência. Oito blocos aparecendo em oito momentos diferentes é pior que três. Agrupe o que deve aparecer junto na mesma fronteira.
Erro dentro de Suspense precisa de error.tsx. Sem ele, uma falha no bloco
mais lento derruba a página inteira depois de ela já ter começado a aparecer — o
pior dos dois mundos.
Alguns intermediários bufferizam. Um proxy ou CDN mal configurado no caminho pode acumular a resposta e entregar de uma vez, anulando o streaming sem nenhum erro. Se funciona local e não em produção, é o primeiro lugar para olhar.
Cabeçalho lido no topo torna tudo dinâmico. Chamar cookies() ou headers()
no componente da página desliga a estaticidade da rota inteira. Faça essa leitura
dentro do bloco que precisa dela, atrás de um Suspense.
Continue por aqui
Performance Web
INP acima de 200 ms: achando a long task que trava o clique
O INP mede o clique mais lento da sessão, não a média. Como achar exatamente qual interação e qual linha de código estão travando a thread principal.
Performance Web
O 'use client' que dobrou o bundle: onde fica a fronteira
A diretiva não marca um arquivo, marca uma fronteira. Tudo que ela importa desce junto para o navegador — inclusive o que você jurava ser código de servidor.
Performance Web
Hydration mismatch no App Router: as 5 causas reais
O erro de hidratação do React quase sempre vem de uma destas cinco origens. Como identificar qual é a sua em minutos, em vez de tentar no escuro.