Streaming SSR v React 19 a Next.js 16: kdy pomáhá výkonu (a kdy ne)
Streaming SSR v React 19 a Next.js 16 nezrychluje vždy. Ukážeme, kdy pomůže LCP, kdy uškodí, jak umístit Suspense boundaries a proč Cloudflare v proxy módu často stream úplně zabije. Praktické tipy z reálných auditů plus příklady pro Node i Edge.
Streaming SSR posílá HTML v inkrementálních částech, takže prohlížeč začne vykreslovat shell dřív, než server dorazí k pomalým datům. V ideálním případě to sníží TTFB o 200 až 400 ms bez zásahu do LCP. Háček je v tom, že to funguje pouze tehdy, když je LCP prvek ve statickém shellu, když CDN nevyrovnává buffer a když jsou Suspense boundaries umístěny na správných místech. Ve Spotify jsem šest let honil TTI regrese v desktop Web Playeru, takže níže popíšu, kdy streaming v React 19 a Next.js 16 skutečně pomůže, kdy naopak škodí a jak najít rozdíl v reálných měřeních z 4G.
Streaming SSR přes renderToPipeableStream (Node) nebo renderToReadableStream (Edge) v React 19 vždy sníží TTFB, ale zlepší LCP pouze tehdy, když je LCP prvek mimo jakoukoli Suspense boundary.
Next.js 16 (říjen 2025) nahradil experimental.ppr stabilní volbou cacheComponents: true. Partial Prerendering je nyní produkčně použitelný.
Suspense boundary kolem LCP prvku LCP zhorší. Fallback se vykreslí první a skutečný obrázek dorazí později v samostatném chunku.
Cloudflare v proxy režimu (orange‑cloud) před Vercel origin může streamované odpovědi vyrovnat do jednoho chunku a streaming úplně zabít. Totéž platí pro AWS ALB před Node originem.
Selective hydration a menší "use client" ostrůvky uvolňují hlavní vlákno a nepřímo zlepšují INP, které je od března 2026 rankingový signál.
renderToNodeStream byl v React 19 odstraněn, takže migrace na pipeable/readable stream je povinná.
Co je streaming SSR a jak funguje v React 19
Streaming server‑side rendering znamená, že server odešle HTML odpověď v několika chunkech přes Transfer-Encoding: chunked nebo HTTP/2 rámce, místo aby čekal na kompletní vyrendrování celého stromu a poslal jedno velké tělo. V React 19 to obsluhují dvě funkce z react-dom/server. renderToPipeableStream je Node varianta, která vrací objekt { pipe, abort } a volá callbacky onShellReady, onShellError, onAllReady a onError. renderToReadableStream vrací Promise<ReadableStream> a je určena pro Edge runtimy (Cloudflare Workers, Vercel Edge, Deno, Bun).
Kritická věc: React 19 odstranil starší renderToNodeStream. Pokud jste na React 18 a máte custom server, migrace je povinná, jinak build spadne. Podrobnosti popisuje oficiální dokumentace renderToPipeableStream.
Praktický Node příklad, který jsem používal v jednom fintech auditu:
import { renderToPipeableStream } from 'react-dom/server';
import { Suspense } from 'react';
export function handler(req, res) {
let didError = false;
const { pipe, abort } = renderToPipeableStream(<App url={req.url} />, {
bootstrapScripts: ['/main.js'],
// Shell je hotov, muzeme zacit flush
onShellReady() {
res.statusCode = didError ? 500 : 200;
res.setHeader('Content-Type', 'text/html');
pipe(res); // trickle chunky do klienta
},
// Vhodne pro SEO crawlery, ktere nezpracuji inline <script> swap
onAllReady() {
// Pokud potrebujete cekat na vse (crawler), pouzijte tady misto onShellReady
},
onShellError(err) {
res.statusCode = 500;
res.setHeader('Content-Type', 'text/html');
res.send('<h1>Server error</h1>');
},
onError(err) {
didError = true;
console.error(err);
},
});
// Bezpecnostni timeout, nikdy nedrzte spojeni vecne
setTimeout(abort, 10_000);
}
Klient dostane shell (všechno mimo Suspense) během desítek milisekund. Fallback HTML z každé Suspense boundary se vloží do dokumentu, a jakmile React dokončí rendering pomalé části, přidá do konce těla inline <script>, který výsledné HTML swapne na místo fallbacku. Žádný nový request, žádný roundtrip navíc.
Kdy streaming SSR skutečně zlepší LCP
TTFB spadne skoro vždy. Ve Spotify jsme viděli shell bytes na wire za 40 až 90 ms, zatímco blokující SSR se stejnými RSC daty potřeboval 350 až 550 ms. To je čistý zisk, protože prohlížeč začne parsovat HTML, discovery <link rel="preload"> a stahovat kritické CSS/fonty dříve. Pokud jste ještě neoptimalizovali server, přečtěte si nejdřív jak snížit TTFB pod 200 ms. Streaming vám do rozpočtu přidá dalších 100 až 200 ms.
LCP se zlepší, když jsou splněny všechny tři podmínky současně:
LCP prvek (hero obrázek, headline, hlavní <video> poster) je ve statickém shellu, tedy mimo jakoukoli Suspense boundary.
Preload hint pro tento asset je taky ve shellu, nikoli v suspended části.
CDN i všechny proxy vrstvy skutečně streamují (viz níže).
V praxi to znamená, že hero sekci renderujete synchronně s daty, která máte v edge cache, a Suspense obalíte teprve okolo věcí typu "recommended for you" nebo real‑time cen. Cíl na p75 mobilu zůstává < 2.5 s podle web.dev průvodce optimalizací LCP.
INP profituje nepřímo: menší synchronní hydration chunky plus selective hydration znamenají, že hlavní vlákno má víc window pro user input během načítání. Naše naměřené hodnoty na content‑heavy travel site: INP p75 kleslo z 340 ms na 190 ms po převodu z monolitického client bundle na RSC s třemi Suspense boundaries. Pokud vaše INP zaostává, podívejte se na techniky pro opravu špatného INP skóre.
Kdy streaming SSR výkonu škodí (a jak to poznat)
Za poslední rok jsem viděl čtyři vzory, kdy streaming výkon zhoršil, přestože TTFB skutečně kleslo. Uvádím je v pořadí, jak často je potkávám.
1. LCP prvek za Suspense. Klasika. Někdo obalil celou hero sekci do <Suspense fallback={<Skeleton/>}>, protože načítá A/B variantu z feature flagu. Skeleton se vyrenderuje jako první paint (dobrý FCP), ale LCP kandidát se určí ze skutečného hero obrázku, který dorazí až po flushnutí druhého chunku. Ten reálně přijde 800 až 1500 ms po shellu.
2. Edge worker CPU quota. Cloudflare Workers na free tieru mají 10 ms CPU / 128 MB paměti; placený tier 50 ms CPU. RSC serializace velkého stromu (200+ komponent, těžké props) tenhle rozpočet spolehlivě přestřelí a odpověď skončí 500. Vercel Fluid Compute je tolerantnější, ale i tam jsem viděl RSC payload nad 400 KB po gzipu, který zablokoval hydration na low‑end Androidech.
3. HTTP/2 head‑of‑line blocking na flaky 4G. Ve chvíli, kdy vypadne jeden TCP paket a probíhá multiplexed přenos, zablokujete celou connection do jeho retransmise. Streamovaný response, který používá stejnou connection jako CSS/JS, může paradoxně zpozdit kritické zdroje. HTTP/3 (QUIC) tenhle problém řeší, ale ne všechny CDN ho ještě mají výchozí.
4. Bufferbloat na pomalých trickle spojích. Prohlížeč nezpracuje chunk okamžitě. WebKit buffruje odpovědi pod 1024 bajtů a Chrome kombinuje krátké chunky v jednom paint tick. Na 2G/slow 3G tak streaming vypadá stejně jako blokující SSR, jen s vyšší režií na serveru. Používám Playwright s page.route throttlingem, abych to reprodukoval:
Skutečné throttling se dělá přes CDP session Network.emulateNetworkConditions. Můj vlastní Rust nástroj pro diff WebPageTest waterfallů to pouští při každém deployi. Ne proto, že bych nedůvěřoval Playwrightu, ale protože chci porovnat p50/p75/p95 mezi dvěma buildy bez ruční práce.
Streaming v Next.js 16: loading.js, Suspense a Partial Prerendering
Next.js 16 vyšel 21. října 2025 na Next.js Conf a přinesl několik změn, které mění, jak o streamování přemýšlet. Turbopack je nyní default, React Compiler stabilní a middleware.ts byl přejmenován na proxy.ts. Nejdůležitější změna pro perf: experimental.ppr flag zmizel a nahradil ho stabilní cacheComponents: true. Detaily jsou v oficiálním blogpostu k Next.js 16.
V App Routeru automaticky obalí loading.js soubor svůj sourozenský page.js plus všechny vnořené layouty do <Suspense> s dodaným fallbackem. Nemusíte psát Suspense ručně, pokud vám stačí per‑route boundary.
// app/dashboard/loading.tsx
export default function Loading() {
// Tento fallback se streamuje okamzite, zatimco page.tsx ceka na data
return <DashboardSkeleton />;
}
// app/dashboard/page.tsx
export default async function Page() {
// Tenhle await se streamuje mimo shell, loading.tsx se ukaze driv
const stats = await fetchStats(); // 400 ms
return <DashboardStats data={stats} />;
}
Metadata jsou v Next.js 16 taky streamovaná: generateMetadata se vyresolvuje asynchronně a Next detekuje non‑JS crawlery jako Twitterbot podle User‑Agent a pro ně na metadata počká, aby OG tagy nechyběly. Pro Googlebot (který dnes běží headless Chrome) streaming funguje bez problému.
Cache Components (cacheComponents: true) posunuly PPR do stabilního stavu. Model je jednoduchý: komponenta bez direktivy "use cache" je dynamická a musí být za Suspense boundary; komponenta s direktivou se prerenderuje do statického shellu s cacheLife a cacheTag ovládáním invalidace. Výsledná stránka má statickou skořápku, která se servíruje z CDN edge, plus dynamické díry, které se dostreamují z originu. Detaily v dokumentaci cacheComponents.
Pár upozornění při migraci na 16: params, cookies(), headers() a searchParams jsou nyní všechny asynchronní a musíte je awaitovat. Parallel routes vyžadují default.js, jinak build spadne. Pokud používáte custom server, ověřte, že váš proxy layer nesetne response body do stream.
Kam umístit Suspense boundaries pro nejlepší Core Web Vitals
Suspense boundary je hranice mezi tím, co server pošle okamžitě, a tím, co dostreamuje. Základní pravidlo, které opakuji svým klientům: jedna boundary na sémantickou sekci stránky, nikoli jedna na komponentu. Konkrétně:
Nikdy neobalujte celý <body> nebo hlavní layout do jedné Suspense. Vytvoří to blokující SSR: shell je prázdný, takže se čeká na cokoli uvnitř. Viděl jsem to na produkci třikrát a všechny tři audity začínaly odstraněním této jedné boundary.
LCP element vždy ve shellu. Když je hero obrázek uvnitř Suspense, LCP se počítá až od dokončení druhého chunku. Fallback (skeleton) LCP nezachrání, protože Chrome LCP algoritmus fallback prvky ignoruje, jakmile je nahradí větší img.
Boundary okolo dat, ne komponent. Uvažujte "co je pomalé (recommendations, personalization, real‑time ceny)" a to obalte. Statický obsah (nadpisy, breadcrumbs, hlavní tělo článku) nechte ve shellu.
Vyhýbejte se nested Suspense > 2 úrovně. Každá úroveň přidá další inline script swap. Nad ~50 boundary jsem viděl reálnou režii v INP.
Praktická šablona pro e‑shop kategorii, která zvládne LCP < 2 s a INP < 200 ms:
// app/kategorie/[slug]/page.tsx
export default async function CategoryPage({ params }) {
const { slug } = await params;
const category = await fetchCategory(slug); // ~80 ms z edge cache
return (
<>
{/* Vse tohle je ve shellu, LCP prvek je category.heroImage */}
<CategoryHero image={category.heroImage} title={category.title} />
<CategoryDescription text={category.description} />
<Breadcrumbs items={category.breadcrumbs} />
{/* Grid produktu: ne LCP, ale kriticky obsah, muze byt za Suspense */}
<Suspense fallback={<ProductGridSkeleton count={12} />}>
<ProductGrid categoryId={category.id} />
</Suspense>
{/* Personalizovane doporuceni: nejpomalejsi, samostatny boundary */}
<Suspense fallback={null}>
<RecommendedForYou userId={/* async */} />
</Suspense>
</>
);
}
Proč Cloudflare‑proxy Next.js buffruje odpovědi místo streamování
Nejčastější root cause "streaming SSR nezrychlil TTFB" v mých auditech: mezi klientem a originem sedí proxy vrstva, která response body vyrovnává. Klient nedostane první chunk, dokud proxy nemá celé tělo, přesně to, čemu jste se chtěli vyhnout.
Cloudflare orange‑cloud A record před Vercel origin je nejběžnější viník. Když má Cloudflare zapnutou transparentní kompresi (Brotli / gzip on the fly) a origin posílá Content-Encoding: gzip, může Cloudflare dekomprimovat, rekomprimovat a přitom setřít Transfer-Encoding: chunked. Fix: buď vypnout Cloudflare kompresi pro tuhle route, nebo přejít na grey‑cloud (DNS‑only) pro dynamické endpointy a nechat Cloudflare jen pro statická aktiva.
AWS Application Load Balancer před Node originem má podobný problém, když použijete request/response mirroring pro logging. Mirror path čte celé tělo a tím ho buffruje. Řešení: mirroring vypnout pro streaming endpointy, nebo použít Network Load Balancer (L4), který stream nechá projít.
Diagnostika je jednoduchá. Použijte curl s --no-buffer proti origin URL a proti proxy URL a porovnejte, kdy začnou přicházet bajty:
# Primo na origin, mel by trickle po chunkech
curl -N --no-buffer -w "\n\nTTFB: %{time_starttransfer}s\n" https://origin.example.com/dashboard
# Pres proxy, pokud je time_starttransfer vyrazne vetsi, proxy buffruje
curl -N --no-buffer -w "\n\nTTFB: %{time_starttransfer}s\n" https://www.example.com/dashboard
Doplňkově zkontrolujte, že vaše CDN vrstva pro cache‑control a stale‑while‑revalidate respektuje Cache‑Control: private, no‑store na streamovaných dynamických odpovědích a necachuje je omylem.
Nejčastější chyby v RSC a hydration mismatches
React Server Components jsou ortogonální ke streamingu. Můžete streamovat bez RSC a používat RSC bez streamingu, ale v Next.js App Routeru se prakticky vždy potkávají. Pár chyb, které jsem opakovaně viděl a které stojí za pár set milisekund nebo za tvrdý crash:
Waterfall v RSC
Dvě sekvenční await v jedné komponentě znamenají waterfall, druhý fetch čeká na první. Fix je Promise.all nebo rozdělit komponenty a nechat React je paralelizovat pod různými Suspense boundaries:
// Spatne: sekvencni, 300 + 400 = 700 ms
async function Page() {
const user = await fetchUser(); // 300 ms
const orders = await fetchOrders(); // 400 ms, ceka na user
return <Dashboard user={user} orders={orders} />;
}
// Spravne: paralelni, max(300, 400) = 400 ms
async function Page() {
const [user, orders] = await Promise.all([fetchUser(), fetchOrders()]);
return <Dashboard user={user} orders={orders} />;
}
"use client" na wrapperu
Direktiva "use client" se aplikuje na komponentu a celý její subtree, dokud narazí na další server komponentu. Když ji dáte na <PageLayout>, celá aplikace se stane client bundle a RSC výhoda zmizí. Direktivu vždy dávejte na listové interaktivní prvky (button, input, chart s onClick), nikdy na wrapper.
Hydration mismatch přes Date.now / locale
V RSC běží Date.now() nebo toLocaleString() jinde než na klientovi, takže vygenerují jiný HTML. React 19 to hlásí jako error #418/#425 v produkci. Řešení: buď formátujte na serveru a předávejte string, nebo použijte suppressHydrationWarning jen na malý inline element (nikoli na parent kontejner).
Async API bez await v Next.js 16
Nejčastější chyba při upgrade na 16: params.slug místo (await params).slug. Do 15.x fungovalo, v 16 to hodí runtime error. Codemod npx @next/codemod@latest next-async-request-api . to opraví.
Streaming SSR vs Partial Prerendering vs SSG
Otázka, kterou dostávám nejčastěji: "Máme použít streaming, PPR, nebo prostě SSG?" Odpověď závisí na tom, kolik obsahu stránky se mění per‑user a per‑request.
Dimenze
SSG / ISR
Streaming SSR
Partial Prerendering (PPR)
TTFB p75
20 až 80 ms (edge)
80 až 150 ms
30 až 100 ms (statický shell z edge)
Personalizace
Pouze client‑side po hydration
Nativní přes RSC + cookies
Nativní v dynamických dírách
Dynamická data
Rebuild nebo revalidate
Vždy fresh
Fresh v dynamických dírách, cachovaný zbytek
Edge worker CPU
Minimální (statický soubor)
Vysoká (celý render)
Střední (jen dynamické díry)
Cache invalidace
ISR revalidate
N/A
cacheTag + cacheLife
Framework
Next 12+, Astro, Nuxt
Next 13+, Remix, Astro
Next 16 stabilní
Kdy použít
Blog, docs, marketing
Feed, dashboard, personalized listing
E‑shop katalog, article s live komentáři
Prakticky: pokud stránka nemá personalizovaný obsah nad fold, jděte do SSG/ISR. Je to nejrychlejší a nejjednodušší. Pokud má personalizaci ve fold (přihlášený uživatel, geografická cena), ale zbytek je statický, PPR je nejlepší kompromis. Streaming SSR bez PPR má smysl pro plně dynamické views typu real‑time dashboard, kde nic nelze cache.
Pro obrázky pak platí, že bez ohledu na strategii SSR/SSG budete potřebovat správný pipeline. K tomu jsem sepsal průvodce optimalizací obrázků v AVIF, WebP a srcset. LCP nezachrání ani nejlepší streaming, když posíláte 2 MB PNG hero image.
Časté dotazy
Kdy mám použít streaming SSR místo statického generování (SSG)?
Streaming SSR použijte, když má stránka personalizovaný obsah nebo dynamická data, která se mění per‑request a nelze je cachovat na edge. Pokud je celý fold statický (blog post, docs, marketing landing), zůstaňte u SSG/ISR. Bude to vždy rychlejší a levnější na server CPU.
Zlepší streaming SSR skutečně LCP, nebo pouze TTFB?
TTFB se zlepší téměř vždy (typicky o 200 až 400 ms). LCP se zlepší jen pokud je LCP prvek ve statickém shellu, tedy mimo jakoukoli Suspense boundary. Když je hero obrázek uvnitř Suspense, LCP se paradoxně zhorší, protože Chrome počká na skutečný obrázek, nikoli na skeleton fallback.
Jaký je rozdíl mezi streaming SSR a Partial Prerendering v Next.js 16?
Streaming SSR renderuje celou stránku dynamicky a posílá HTML v chunkech. Partial Prerendering (Cache Components v Next.js 16) prerenderuje statický shell do CDN edge a jen dynamické díry za Suspense boundaries se dostreamují z originu. PPR má lepší TTFB, protože shell servíruje edge cache bez volání originu.
Proč mi Cloudflare‑proxy Next.js buffruje odpovědi místo streamování?
Cloudflare v proxy módu (orange cloud) může streamované odpovědi vyrovnat, když má zapnutou transparentní kompresi, která setře Transfer-Encoding: chunked. Řešení: přepněte na DNS‑only (grey cloud) pro dynamické endpointy nebo vypněte Auto Minify a Brotli kompresi pro tyto route. Diagnostikujte přes curl --no-buffer.
Nahrazují React Server Components getServerSideProps, a potřebuji ještě renderToPipeableStream?
V App Routeru RSC funkčně nahrazují getServerSideProps, fetch se dělá přímo v async server komponentě. renderToPipeableStream potřebujete jen když máte custom Node server mimo Next.js. Next.js App Router používá pipeable/readable stream interně, takže s ním nepracujete přímo.
Kam umístit Suspense boundaries pro nejlepší Core Web Vitals?
Jednu boundary na sémantickou sekci stránky (feed, sidebar, recommendations), nikoli jednu na komponentu. LCP prvek nechte vždy ve shellu. Nepoužívejte víc než dvě úrovně vnořených boundaries. Každá přidá inline script swap a zatěžuje hlavní vlákno.
Daniel started in performance work on the SRE side. He spent six years at Spotify on the Web Player team, where he owned the TTI regression budget for the desktop web app and built the internal dashboard that flagged perf regressions per PR before merge. He left in 2023 to join a small consultancy doing performance audits for fintech and travel companies, mostly in the UK and Nigeria.
His subspecialty is server-side rendering tradeoffs: when streaming SSR actually helps, when it makes things worse on flaky 4G, and the real numbers behind React Server Components for content-heavy sites. He's a heavy Playwright user for perf testing, mistrusts most npm dependencies on principle, and is currently writing a small Rust tool to diff WebPageTest waterfalls across deploys. Outside of work he coaches a junior dev meetup in Manchester.
Kompletní průvodce optimalizací webových fontů v roce 2026. Self-hosting WOFF2, preload, subsetting přes unicode-range, variable fonts a size-adjust pro nulový CLS, včetně praktických ukázek kódu z reálných auditů.
Nativní API v Chrome pro deklarativní prerender stránek a navigaci pod 100 ms. Návod: syntaxe, eagerness úrovně, omezení Cache-Control, ladění v DevTools.
Praktický průvodce Cache-Control a stale-while-revalidate v roce 2026. CDN vzory, Next.js 15 ISR, hlavička Cache-Status z RFC 9211 a časté chyby s ukázkami kódu.