Pular para o conteúdo
upgbp

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.

9 min de leitura

Ambiente testado

  • Next.js 15.3
  • React 19.1
  • Vercel · Node 22
Neste artigo (9)

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

Chrome DevTools · Network
GET /painel
  Waiting for server response (TTFB) .... 3.21 s
  Content Download ....................... 0.04 s

Trê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

src/app/painel/page.tsx
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.

Três linhas do tempo com as mesmas quatro buscas. Com await em série o primeiro byte sai em 3,21 s; com Promise.all, em 1,69 s; com streaming e Suspense, em 0,09 s, e cada bloco chega quando fica pronto.
O tempo total quase não muda. Muda quando o usuário vê a página.

A solução

  1. 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.tsx
    export async function Ranking() {
      const dados = await buscarRanking();
      return <TabelaRanking dados={dados} />;
    }
  2. Envolver cada um em Suspense.

    Cada fronteira de Suspense é um ponto onde o React pode cortar a resposta: ele manda o fallback agora e o conteúdo real depois.

    src/app/painel/page.tsx
    import { 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.

  3. 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.tsx
    export 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
        />
      );
    }
  4. 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 await traz o waterfall de volta. A saída é começar a busca sem esperar e deixar cada filho consumir com use():

    src/app/painel/page.tsx
    export 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.tsx
    import { use } from 'react';
    
    export function Cabecalho({ usuarioPromise }: { usuarioPromise: Promise<Usuario> }) {
      const usuario = use(usuarioPromise);
      return <h2>Olá, {usuario.nome}</h2>;
    }
  5. Cuidar do layout.tsx, que bloqueia tudo.

    Um await no layout segura todas as páginas abaixo dele, e nenhuma fronteira de Suspense interna resolve isso. Se o layout precisa de dado, ele também precisa de Suspense — em volta da parte que depende, nunca em volta do children.

  6. Usar loading.tsx para a navegação entre rotas.

    Ele é um Suspense no nível da rota inteira, aplicado durante a transição:

    src/app/painel/loading.tsx
    import { 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:

Chrome DevTools · Network
GET /painel
  Waiting for server response (TTFB) .... 0.09 s
  Content Download ....................... 1.71 s   ← chegando em pedaços

O 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