React & Frontend

Streaming SSR com React: Suspense no Servidor e Progressive Hydration na Prática

16 min de leitura

Streaming SSR com React: Suspense no Servidor e Progressive Hydration na Prática

O Que É Streaming SSR com React? Streaming SSR (Server-Side Rendering com Streaming) é uma técnica avançada de renderização que permite enviar o HTML do servidor para o cliente progressivamente, em chunks, em vez de esperar que toda a página seja renderizada antes de enviar qualquer coisa. Isso melhora significativamente o Time to First Byte (TTFB) e a experiência do usuário, especialmente em conexões mais lentas. No React, essa abordagem ganhou força com a introdução do no servidor. Diferentemente do tradicional que trabalha apenas no cliente (suspendendo a renderização enquanto dados são carregados), o no servidor permite pausar a renderização de componentes específicos e continuar renderizando o resto da página, enviando aquele componente posteriormente quando seus dados estiverem prontos. Imagine uma página de e-commerce: você pode enviar o header, navegação e produtos em destaque imediatamente, enquanto carrega as recomendações personalizadas em background, sem bloquear a experiência visual. O usuário vê conteúdo útil rapidamente e as partes mais pesadas chegam depois.

<h2>O Que É Streaming SSR com React?</h2>

<p>Streaming SSR (Server-Side Rendering com Streaming) é uma técnica avançada de renderização que permite enviar o HTML do servidor para o cliente <strong>progressivamente</strong>, em chunks, em vez de esperar que toda a página seja renderizada antes de enviar qualquer coisa. Isso melhora significativamente o Time to First Byte (TTFB) e a experiência do usuário, especialmente em conexões mais lentas.</p>

<p>No React, essa abordagem ganhou força com a introdução do <code>Suspense</code> no servidor. Diferentemente do <code>Suspense</code> tradicional que trabalha apenas no cliente (suspendendo a renderização enquanto dados são carregados), o <code>Suspense</code> no servidor permite pausar a renderização de componentes específicos e continuar renderizando o resto da página, enviando aquele componente posteriormente quando seus dados estiverem prontos.</p>

<p>Imagine uma página de e-commerce: você pode enviar o header, navegação e produtos em destaque imediatamente, enquanto carrega as recomendações personalizadas em background, sem bloquear a experiência visual. O usuário vê conteúdo útil rapidamente e as partes mais pesadas chegam depois.</p>

<h2>Suspense no Servidor: Fundamentos</h2>

<h3>Como Funciona o Suspense no Servidor</h3>

<p>O <code>Suspense</code> no servidor funciona diferente do cliente. Quando você coloca um componente dentro de um boundary de Suspense no servidor, se esse componente &quot;se suspender&quot; (lançar uma promise que ainda não foi resolvida), o React não renderiza o <code>fallback</code> imediatamente. Em vez disso, pausa aquele trecho específico e continua renderizando o resto da árvore.</p>

<p>O servidor envia o HTML já renderizado para as partes que estão prontas, e quando o componente suspenso terminar seu carregamento, o React envia aquele pedaço como um update HTML especial, que o cliente integra à página (através do mecanismo de <code>&lt;template&gt;</code> e instruções de serialização).</p>

<p>Isso é fundamentalmente diferente do cliente, onde o <code>Suspense</code> bloqueia toda a renderização até que os dados cheguem. No servidor, você ganha paralelismo natural.</p>

<h3>Exemplo Básico: Suspense Server-Side</h3>

<pre><code class="language-javascript">// app/components/Product.js

import { Suspense } from &#039;react&#039;;

import { db } from &#039;@/lib/database&#039;;

async function ProductDetails({ id }) {

// Este é um componente assíncrono que roda no servidor

const product = await db.products.findById(id);

return (

&lt;div className=&quot;product&quot;&gt;

&lt;h2&gt;{product.name}&lt;/h2&gt;

&lt;p&gt;{product.description}&lt;/p&gt;

&lt;span className=&quot;price&quot;&gt;${product.price}&lt;/span&gt;

&lt;/div&gt;

);

}

function ProductFallback() {

return &lt;div className=&quot;skeleton&quot;&gt;Carregando produto...&lt;/div&gt;;

}

export function ProductCard({ id }) {

return (

&lt;Suspense fallback={&lt;ProductFallback /&gt;}&gt;

&lt;ProductDetails id={id} /&gt;

&lt;/Suspense&gt;

);

}</code></pre>

<pre><code class="language-javascript">// app/page.js (usando Next.js App Router como exemplo)

import { ProductCard } from &#039;./components/Product&#039;;

export default function HomePage() {

return (

&lt;div className=&quot;container&quot;&gt;

&lt;h1&gt;Bem-vindo à Nossa Loja&lt;/h1&gt;

&lt;section className=&quot;featured&quot;&gt;

&lt;h2&gt;Produtos em Destaque&lt;/h2&gt;

&lt;ProductCard id=&quot;1&quot; /&gt;

&lt;ProductCard id=&quot;2&quot; /&gt;

&lt;ProductCard id=&quot;3&quot; /&gt;

&lt;/section&gt;

&lt;/div&gt;

);

}</code></pre>

<p>Neste exemplo, o React renderiza o título e a seção no servidor imediatamente. Enquanto isso, cada <code>ProductCard</code> com seu <code>Suspense</code> começa a carregar os dados do banco. À medida que cada produto fica pronto, seu HTML é enviado para o cliente em um stream separado, não bloqueando os outros.</p>

<h3>Diferença Entre Suspense no Cliente vs Servidor</h3>

<p>No <strong>cliente</strong>, quando você usa <code>Suspense</code>:</p>

<ul>

<li>Toda a árvore dentro do boundary pausa</li>

<li>O fallback é mostrado até o carregamento terminar</li>

<li>Nada mais é renderizado enquanto aguarda</li>

</ul>

<p>No <strong>servidor</strong>, quando você usa <code>Suspense</code>:</p>

<ul>

<li>Apenas aquele branch específico pausa</li>

<li>O resto da árvore continua sendo renderizada e enviada</li>

<li>O componente suspenso é enviado depois, como um update</li>

</ul>

<p>Isso significa que o streaming SSR + Suspense oferece o melhor dos dois mundos: renderização rápida para o usuário e componentes que se carregam independentemente.</p>

<h2>Progressive Hydration: Ativando Componentes Gradualmente</h2>

<h3>O Que É Hydration e Por Que Fazer Progressivamente?</h3>

<p>Hydration é o processo onde o React &quot;anima&quot; o HTML estático que veio do servidor, anexando event listeners e tornando a página interativa. Normalmente, o navegador baixa todo o JavaScript, executa o código React, e só então a página fica 100% interativa.</p>

<p>A <strong>Progressive Hydration</strong> quebra esse processo em pequenas partes: o JavaScript é carregado em chunks menores e a interatividade é ativada componente por componente, conforme os dados necessários chegam. Isso significa que o usuário pode interagir com partes da página bem antes de todo o JavaScript ter sido baixado e processado.</p>

<p>Imagine uma página com um header, um sidebar com filtros e conteúdo principal. Com progressive hydration, o header fica interativo primeiro (é simples e pequeno), depois o sidebar (tem mais lógica), e por último o conteúdo (é o mais pesado). O usuário não precisa esperar pelo componente mais pesado para clicar no header.</p>

<h3>Implementando Progressive Hydration</h3>

<p>O Next.js 13+ com App Router fornece suporte automático para isso através do <code>&quot;use client&quot;</code> com lazy loading. Veja como:</p>

<pre><code class="language-javascript">// app/components/UserMenu.js

&#039;use client&#039;;

import { useState } from &#039;react&#039;;

export function UserMenu({ user }) {

const [isOpen, setIsOpen] = useState(false);

return (

&lt;div className=&quot;user-menu&quot;&gt;

&lt;button onClick={() =&gt; setIsOpen(!isOpen)}&gt;

{user.name}

&lt;/button&gt;

{isOpen &amp;&amp; (

&lt;div className=&quot;dropdown&quot;&gt;

&lt;a href=&quot;/profile&quot;&gt;Perfil&lt;/a&gt;

&lt;a href=&quot;/settings&quot;&gt;Configurações&lt;/a&gt;

&lt;a href=&quot;/logout&quot;&gt;Sair&lt;/a&gt;

&lt;/div&gt;

)}

&lt;/div&gt;

);

}</code></pre>

<pre><code class="language-javascript">// app/components/Comments.js

&#039;use client&#039;;

import { useState } from &#039;react&#039;;

import dynamic from &#039;next/dynamic&#039;;

// Carrega o componente pesado de forma lazy

const CommentForm = dynamic(

() =&gt; import(&#039;./CommentForm&#039;),

{

loading: () =&gt; &lt;p&gt;Carregando formulário de comentários...&lt;/p&gt;,

ssr: false // Não renderiza no servidor, apenas no cliente

}

);

export function Comments({ postId }) {

const [showForm, setShowForm] = useState(false);

return (

&lt;div className=&quot;comments&quot;&gt;

{showForm &amp;&amp; &lt;CommentForm postId={postId} /&gt;}

&lt;button onClick={() =&gt; setShowForm(!showForm)}&gt;

{showForm ? &#039;Cancelar&#039; : &#039;Adicionar Comentário&#039;}

&lt;/button&gt;

&lt;/div&gt;

);

}</code></pre>

<pre><code class="language-javascript">// app/layout.js

import { UserMenu } from &#039;./components/UserMenu&#039;;

import { Comments } from &#039;./components/Comments&#039;;

export default async function RootLayout({ children }) {

const user = await fetchUser(); // Dados do servidor

return (

&lt;html&gt;

&lt;body&gt;

&lt;header&gt;

&lt;UserMenu user={user} /&gt;

&lt;/header&gt;

&lt;main&gt;{children}&lt;/main&gt;

&lt;Comments postId=&quot;123&quot; /&gt;

&lt;/body&gt;

&lt;/html&gt;

);

}</code></pre>

<p>Neste cenário:</p>

<ol>

<li>O HTML é renderizado no servidor com conteúdo estático</li>

<li>O <code>UserMenu</code> é um client component simples e hidrata rapidamente</li>

<li>O <code>Comments</code> carrega seu bundle JavaScript apenas quando necessário</li>

<li>A <code>CommentForm</code> dentro de <code>Comments</code> é carregada de forma lazy, apenas quando o usuário clica em &quot;Adicionar Comentário&quot;</li>

</ol>

<p>O navegador recebe o HTML completo da página bem rápido, mas a interatividade é ativada em ondas: primeiro o header, depois o conteúdo principal, depois as partes mais pesadas sob demanda.</p>

<h2>Padrões Avançados e Otimizações</h2>

<h3>Combinando Suspense + Progressive Hydration</h3>

<p>A verdadeira potência vem quando você combina essas duas técnicas. Veja um exemplo realista:</p>

<pre><code class="language-javascript">// app/components/ProductGrid.js

&#039;use client&#039;;

import { Suspense } from &#039;react&#039;;

import dynamic from &#039;next/dynamic&#039;;

import { ProductCard } from &#039;./ProductCard&#039;;

// Carrega o filtro de forma lazy

const ProductFilters = dynamic(() =&gt; import(&#039;./ProductFilters&#039;), {

loading: () =&gt; &lt;div className=&quot;filters-skeleton&quot;&gt;Carregando filtros...&lt;/div&gt;,

ssr: true // Renderiza no servidor também para SEO

});

// Carrega as recomendações de forma lazy

const RecommendedProducts = dynamic(

() =&gt; import(&#039;./RecommendedProducts&#039;),

{

ssr: false,

loading: () =&gt; &lt;div className=&quot;recommendations-skeleton&quot;&gt;Carregando recomendações...&lt;/div&gt;

}

);

async function ProductList({ category }) {

// Componente assíncrono que busca produtos

const products = await fetchProductsByCategory(category);

return (

&lt;div className=&quot;product-list&quot;&gt;

{products.map(product =&gt; (

&lt;ProductCard key={product.id} product={product} /&gt;

))}

&lt;/div&gt;

);

}

export function ProductGrid({ category }) {

return (

&lt;div className=&quot;grid-container&quot;&gt;

&lt;aside&gt;

&lt;ProductFilters /&gt;

&lt;/aside&gt;

&lt;main&gt;

&lt;Suspense

fallback={

&lt;div className=&quot;grid-skeleton&quot;&gt;

Carregando produtos...

&lt;/div&gt;

}

&gt;

&lt;ProductList category={category} /&gt;

&lt;/Suspense&gt;

&lt;section className=&quot;recommendations&quot;&gt;

&lt;RecommendedProducts /&gt;

&lt;/section&gt;

&lt;/main&gt;

&lt;/div&gt;

);

}</code></pre>

<p>Aqui, o que acontece no fluxo é:</p>

<ol>

<li>O servidor renderiza o layout imediatamente</li>

<li>O <code>ProductFilters</code> é um client component que recebe seu JavaScript na primeira onda (é simples)</li>

<li>A <code>ProductList</code> dentro do Suspense começa a buscar dados assincronamente</li>

<li>O <code>RecommendedProducts</code> é carregado de forma lazy e não renderizado no servidor (ssr: false)</li>

<li>Conforme os dados chegam, cada seção é hidratada e ativada independentemente</li>

</ol>

<h3>Otimizando com Streaming Direto</h3>

<p>Você também pode usar a API de streaming do Node.js diretamente para mais controle:</p>

<pre><code class="language-javascript">// app/api/stream-products/route.js

import { ReactDOMServer } from &#039;react-dom/server&#039;;

export async function GET(req) {

const { category } = req.nextUrl.searchParams;

return new Response(

new ReadableStream({

async start(controller) {

try {

// Envia o opening HTML

controller.enqueue(

new TextEncoder().encode(

&#039;&lt;div class=&quot;products-container&quot;&gt;&#039;

)

);

// Busca produtos em chunks

const productChunks = await fetchProductsInChunks(category);

for (const chunk of productChunks) {

const html = chunk

.map(product =&gt; `

&lt;div class=&quot;product&quot; key=&quot;${product.id}&quot;&gt;

&lt;h3&gt;${product.name}&lt;/h3&gt;

&lt;p&gt;$${product.price}&lt;/p&gt;

&lt;/div&gt;

`)

.join(&#039;&#039;);

controller.enqueue(new TextEncoder().encode(html));

// Simula latência de rede

await new Promise(resolve =&gt; setTimeout(resolve, 100));

}

controller.enqueue(

new TextEncoder().encode(&#039;&lt;/div&gt;&#039;)

);

controller.close();

} catch (error) {

controller.error(error);

}

}

}),

{

headers: {

&#039;Content-Type&#039;: &#039;text/html; charset=utf-8&#039;,

&#039;Transfer-Encoding&#039;: &#039;chunked&#039;

}

}

);

}

async function fetchProductsInChunks(category) {

// Simula busca de dados em chunks

const allProducts = await db.products

.where({ category })

.toArray();

const chunkSize = 5;

const chunks = [];

for (let i = 0; i &lt; allProducts.length; i += chunkSize) {

chunks.push(allProducts.slice(i, i + chunkSize));

}

return chunks;

}</code></pre>

<p>Este padrão é mais baixo nível e oferece controle total sobre o que é enviado e quando, útil para APIs customizadas.</p>

<h3>Medindo e Monitorando Performance</h3>

<p>Para validar que suas otimizações funcionam, monitore estas métricas:</p>

<pre><code class="language-javascript">// app/components/PerformanceMonitor.js

&#039;use client&#039;;

import { useEffect } from &#039;react&#039;;

export function PerformanceMonitor() {

useEffect(() =&gt; {

const navigationTiming = performance.getEntriesByType(&#039;navigation&#039;)[0];

const paintEntries = performance.getEntriesByType(&#039;paint&#039;);

const ttfb = navigationTiming.responseStart - navigationTiming.fetchStart;

const fcp = paintEntries.find(entry =&gt; entry.name === &#039;first-contentful-paint&#039;)?.startTime;

const lcp = performance.getEntriesByType(&#039;largest-contentful-paint&#039;).pop()?.startTime;

console.log(&#039;TTFB (ms):&#039;, ttfb);

console.log(&#039;FCP (ms):&#039;, fcp);

console.log(&#039;LCP (ms):&#039;, lcp);

// Envie para seu serviço de analytics

if (window.analytics) {

window.analytics.track(&#039;performance_metrics&#039;, {

ttfb,

fcp,

lcp

});

}

}, []);

return null;

}</code></pre>

<p><strong>Métricas importantes:</strong></p>

<ul>

<li><strong>TTFB (Time to First Byte)</strong>: Deve reduzir significativamente com streaming</li>

<li><strong>FCP (First Contentful Paint)</strong>: Quando o primeiro conteúdo aparece na tela</li>

<li><strong>LCP (Largest Contentful Paint)</strong>: Quando o maior elemento é renderizado</li>

<li><strong>INP (Interaction to Next Paint)</strong>: Responsividade dos cliques</li>

</ul>

<h2>Conclusão</h2>

<p>O streaming SSR com React Suspense e progressive hydration representa uma evolução significativa na arquitetura web moderna. Três pontos essenciais foram cobertos: primeiro, o <strong>Suspense no servidor permite paralelizar o carregamento de dados</strong>, renderizando partes independentes da página sem bloquear outras, enviando-as progressivamente ao cliente através de chunks HTML. Segundo, a <strong>progressive hydration ativa a interatividade do usuário em ondas</strong>, permitindo que clique em componentes simples enquanto os pesados ainda estão sendo baixados e processados. Terceiro, essas duas técnicas combinadas criam uma <strong>experiência de usuário perceptivelmente mais rápida</strong>, especialmente em conexões lentas, porque o usuário vê e consegue interagir com conteúdo útil bem antes de toda a página estar completamente pronta.</p>

<p>Não se trata apenas de fazer a página &quot;aparecer&quot; mais rápido, mas de otimizar o tempo até o usuário poder realmente fazer algo útil. Isso é o que diferencia uma aplicação web moderna de uma tradicional.</p>

<h2>Referências</h2>

<ul>

<li><a href="https://react.dev/reference/react/Suspense" target="_blank" rel="noopener noreferrer">React Official Documentation - Suspense</a></li>

<li><a href="https://nextjs.org/docs/app/building-your-application/routing/loading-ui-and-streaming" target="_blank" rel="noopener noreferrer">Next.js Documentation - Streaming and Suspense</a></li>

<li><a href="https://web.dev/articles/react-server-components" target="_blank" rel="noopener noreferrer">Web.dev - React Server Components</a></li>

<li><a href="https://vercel.com/blog/react-18-release" target="_blank" rel="noopener noreferrer">Vercel Blog - React 18 Features</a></li>

<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events" target="_blank" rel="noopener noreferrer">MDN - Server-Sent Events (SSE) for Streaming</a></li>

</ul>

<p>&lt;!-- FIM --&gt;</p>

Comentários

Mais em React & Frontend

Boas Práticas de Testing Library em Profundidade: Queries, Fire Events e Async para Times Ágeis
Boas Práticas de Testing Library em Profundidade: Queries, Fire Events e Async para Times Ágeis

Testing Library em Profundidade: Queries, Fire Events e Async Testing Library...

Monorepo de Componentes React: Storybook, Chromatic e Releases: Do Básico ao Avançado
Monorepo de Componentes React: Storybook, Chromatic e Releases: Do Básico ao Avançado

O Que é um Monorepo de Componentes React Um monorepo (repositório monolítico)...

Como Usar Micro-frontends com React: Module Federation e Arquitetura Distribuída em Produção
Como Usar Micro-frontends com React: Module Federation e Arquitetura Distribuída em Produção

O que são Micro-frontends e Por Que Module Federation? Micro-frontends repres...