
A page has fast parts (navigation bar, layout, static text) and slow parts (database queries, API calls). Traditional SSR makes the fast parts wait for the slowest one. Streaming changes that.
This is the most powerful — and most misunderstood — feature of Next.js. Let's go all the way to the protocol level.
export default async function Dashboard() {
const revenue = await getRevenue() // 200ms
const orders = await getOrders() // 1000ms
const recommendations = await getRecs() // 3000ms
return (
<div>
<h1>Dashboard</h1>
<Revenue data={revenue} />
<Orders data={orders} />
<Recommendations data={recommendations} />
</div>
)
}Without streaming, the browser receives nothing for 3000ms. <h1>Dashboard</h1> — which needs zero data — waits for the slowest query. The user stares at a white screen.
import { Suspense } from 'react'
export default function Dashboard() {
return (
<div>
<h1>Dashboard</h1> {/* Static shell — sent immediately */}
<Suspense fallback={<RevenueSkeleton />}>
<Revenue /> {/* Streams when ready */}
</Suspense>
<Suspense fallback={<OrdersSkeleton />}>
<Orders /> {/* Streams independently */}
</Suspense>
<Suspense fallback={<RecsSkeleton />}>
<Recommendations /> {/* Streams last */}
</Suspense>
</div>
)
}Now the timeline is radically different:
t=0ms: Server sends <h1>Dashboard</h1> + three skeleton placeholders
Browser renders immediately — the user sees the page frame
t=200ms: Revenue resolves → server streams actual revenue HTML
t=1000ms: Orders resolves → server streams actual orders HTML
t=3000ms: Recommendations resolves → server streams actual recommendations HTMLEach <Suspense> boundary is an independent streaming point. Fast components don't wait for slow ones.
Streaming uses the HTTP/1.1 Transfer-Encoding: chunked mechanism. The server sends multiple chunks on the same TCP connection:
HTTP/1.1 200 OK
Transfer-Encoding: chunked
[chunk 1] <html><head>...</head><body><h1>Dashboard</h1>
<template id="B:0"></template><div>Loading revenue...</div>
<template id="B:1"></template><div>Loading orders...</div>
[chunk 2] <div hidden id="S:0"><!-- Revenue HTML --></div>
<script>$RC("B:0", "S:0")</script>
[chunk 3] <div hidden id="S:1"><!-- Orders HTML --></div>
<script>$RC("B:1", "S:1")</script>
0 ← Terminates the streamThe magic is in the <template> tags and the $RC() function:
<template id="B:0"> — a placeholder. Browsers store <template> content without rendering it.<div hidden id="S:0"> — the resolved content, initially hidden to prevent flickering.$RC("B:0", "S:0") — React's swap function: removes the placeholder + fallback, inserts the real content.This is a pure DOM operation. No CSS tricks, no display toggling — the DOM nodes are literally replaced.
Everything rendered before any <Suspense> boundary resolves is called the static shell. It's sent in the very first chunk and includes:
<link> and <script> tags for CSS and JSBecause the static shell arrives first, the browser discovers <link rel="stylesheet"> and <script> tags immediately. Resources start downloading while the server is still generating content. This parallelism is a significant performance win.
The most important optimization pattern: never await dynamic data at the top of a layout or page.
// Bad — entire layout becomes dynamic
export default async function Layout({ children }) {
const user = await getUser() // BLOCKS the entire layout
return <div><Nav user={user} />{children}</div>
}
// Good — make the child async, wrapped in Suspense
export default function Layout({ children }) {
return (
<div>
<Suspense fallback={<NavSkeleton />}>
<Nav /> {/* Nav is async; await happens inside */}
</Suspense>
{children}
</div>
)
}
async function Nav() {
const user = await getUser() // Suspense catches this
return <nav>{user.name}</nav>
}The same principle applies to cookies(), headers(), params, and searchParams. Keep the parent component synchronous (no async), and move the await into a child <Suspense> boundary where the data is actually consumed.
<Suspense>| loading.tsx | <Suspense> | |
|---|---|---|
| Scope | Entire page | Any component |
| Setup | Drop a file | Wrap components |
| Prefetch behavior | Prefetched as instant fallback | Not prefetched by default |
| Best for | Pages with no renderable content before data | Most pages (granular control) |
loading.tsx is syntactic sugar — Next.js wraps page.tsx in a <Suspense> boundary with your loading component as the fallback. Prefer explicit <Suspense> boundaries close to the data source for finer control.
A critical concern: does streaming harm SEO? Next.js handles this with automatic User-Agent detection:
Starting from Next.js 15, Streaming Metadata changes this entirely: generateMetadata does not block the initial HTML for JS-capable clients (including Googlebot). Metadata tags — <title>, <meta>, Open Graph — are appended to <body> after generateMetadata resolves, while the static shell streams immediately.
For HTML-limited bots (Facebook, Twitter, Slack), Next.js detects their User-Agent and falls back to blocking: metadata stays in <head>, then the full HTML is sent at once.
<Suspense> is an independent streaming point — fast parts don't wait for slow parts.<template> placeholders + $RC() swaps replace fallback UI with resolved content via pure DOM operations.await inside <Suspense>, not at the layout/page top level.Previous:
Next: