
Master this article, and you can answer the interview question: "Walk me through exactly what happens in a Next.js HTTP request — from first byte to interactive page."
<template>, $RC, and Payload appear'use client' does — and what it doesn't doYou should have read the SSR tutorial, or at least understand renderToString, hydrateRoot, and Webpack client-side bundling.
After create-next-app, you write your first page:
// app/page.tsx
export default function Home() {
return <h1>Hello Next.js</h1>
}Open DevTools → Network → Response. You see a complete HTML document. Behind the scenes is the traditional SSR pipeline: renderToString(Home()) waits for the component to render, assembles an HTML string, and sends it all at once.
But real apps are never just one <h1>. Let's add things one at a time and see how Next.js handles each.
// app/page.tsx
async function SlowGreeting() {
const res = await fetch('https://api.example.com/name') // ~200ms
const data = await res.json()
return <h1>Hello {data.name}</h1>
}
export default function Home() {
return (
<div>
<p>Welcome to my blog</p> // No data needed
<SlowGreeting /> // Needs 200ms
</div>
)
}The problem: traditional renderToString must wait for the entire tree to render before returning anything. <p>Welcome to my blog</p> needs no data, yet it's blocked by SlowGreeting. The user stares at a white screen for 200ms.
What we want: send the already-rendered parts immediately, and fill in the slow parts later.
import { Suspense } from 'react'
export default function Home() {
return (
<div>
<p>Welcome to my blog</p>
<Suspense fallback={<p>Loading name...</p>}>
<SlowGreeting />
</Suspense>
</div>
)
}The server no longer waits for the entire SlowGreeting. It sends two segments:
<p>Welcome to my blog</p> ← Rendered DOM
<template id="B:0"></template> ← Invisible marker (B = Boundary)
<p>Loading name...</p> ← Fallback, user sees thisThe browser renders immediately — user sees Welcome to my blog and Loading name.... Not a white screen.
<div hidden id="S:0"><h1>Hello Alice</h1></div> ← Real content, hidden to prevent flash
<script>$RC("B:0","S:0")</script> ← Execute the swap$RC does exactly three things:
Before: After:
<p>Welcome...</p> <p>Welcome...</p>
<template id="B:0"> ← marker <h1>Hello Alice</h1> ← Real content replaces marker+fallback
<p>Loading name...</p> ← fallbackPseudo-code for $RC:
const template = document.getElementById("B:0") // Find the marker
template.nextSibling.remove() // Delete fallback
template.parentNode.replaceChild( // Replace marker with real content
document.getElementById("S:0"), template)Pure DOM operations. No CSS tricks involved.
This is Next.js streaming's core mechanism: Suspense splits one HTTP response into multiple chunks — fast parts arrive first, slow parts catch up later. At the HTTP level, this uses Transfer-Encoding: chunked: the server writes and flushes in segments, the browser parses and renders each segment as it arrives.
// app/LikeButton.tsx
'use client'
import { useState } from 'react'
export function LikeButton({ likes }: { likes: number }) {
const [count, setCount] = useState(likes)
return (
<button onClick={() => setCount(c => c + 1)}>
❤️ {count}
</button>
)
}Add it to the page:
import { Suspense } from 'react'
import { LikeButton } from './LikeButton'
export default function Home() {
return (
<div>
<p>Welcome to my blog</p>
<Suspense fallback={<p>Loading name...</p>}>
<SlowGreeting />
</Suspense>
<LikeButton likes={42} />
</div>
)
}'use client' Actually Does — and Doesn't DoCheck the first segment of the response. You'll find <button>❤️ 42</button> is already in the HTML:
<!-- First segment's actual content -->
<p>Welcome to my blog</p>
<template id="B:0"></template>
<p>Loading name...</p>
<button>❤️ 42</button> ← LikeButton's DOM is already here!'use client' does NOT mean "skip server rendering" — the server still renders LikeButton as <button>❤️ 42</button> and sends it as HTML to the browser.
'use client' does exactly one thing: tells Webpack to bundle LikeButton's source code into a separate JS file (/_next/static/chunks/abc.js), and attaches a reference number in the Payload pointing to that file.
At the end of the first segment, alongside the HTML, there's a special <script>:
self.__next_f.push([1,
// Entry 0: LikeButton reference declaration
"0:[\"$\",\"$L1\",null,{\"likes\":42}]\n" +
// Entry 1: $L1 metadata — where is the JS file
"1:{\"name\":\"LikeButton\",\"chunks\":[\"/_next/static/chunks/abc.js\"]}\n" +
// Entry 2: The complete component tree structure
"2:[\"$\",\"div\",null,{\"children\":[\n" +
" \"Welcome to my blog\",\n" + // <p> content
" [\"$\",\"$L2\",null,{}],\n" + // Suspense placeholder
" [\"$\",\"$L1\",null,{\"likes\":42}]\n" + // LikeButton
"]}]\n"
])This is the RSC Payload (React Flight protocol). It contains exactly three kinds of data:
═══════════════════════════════════════════
1. React Elements (Server Component output)
═══════════════════════════════════════════
"Welcome to my blog"
→ A plain string — the content of the <p> tag.
→ The Server Component already executed on the server.
The Payload stores the final value, not the code.
["$","div",null,{"children":[...]}]
│ │ │ └── props, resolved to final values
│ │ └── key
│ └── tag name, "div"
└── "$" signals "this is a React element"
═══════════════════════════════════════════
2. Client Component References
═══════════════════════════════════════════
["$","$L1",null,{"likes":42}]
│ │ │
│ │ └── props, serializable
│ └── "$L1" = Client Component #1
│ JS file: /_next/static/chunks/abc.js
└── "$" signals a React element
$L1 is just a reference number — not JS source code.
The actual JS file is downloaded via a separate HTTP request.
═══════════════════════════════════════════
3. Plain Data
═══════════════════════════════════════════
"Alice" → string
42 → number
null → null
{likes: 42} → object
═══════════════════════════════════════════The string "Welcome to my blog" is already in the HTML — the browser rendered <p>Welcome to my blog</p>. So why does the Payload store it again?
Because React hydration requires node-by-node comparison of the entire component tree. Hydration is not selective — it's a full recursive traversal:
Virtual DOM from Payload: Real DOM:
<div> <p>Welcome to my blog</p> ← Match ✅
"Welcome to my blog" (corresponds to Payload string)
$L2 (Suspense placeholder) <template id="B:0"> ← Match ✅
$L1 {likes: 42} <button>❤️ 42</button> ← Match ✅ → bind events
If the Payload skipped "Welcome":
Virtual DOM's first child becomes $L2
→ Real DOM's first child is <p>
→ Type mismatch → hydration mismatch errorAnalogy: a security guard checking faces against a list. Zhang is just here for dinner, Li is just here for dinner — they need no special arrangements, but you must verify both. Skip one, and everyone after them is misaligned.
The Payload must contain the entire tree, root to leaf, without a single gap. This is also why 'use client' components appear in the Payload — their <button> must participate in the comparison.
Above we covered initial loads (typing a URL). There's another case: clicking <Link> within the app.
The browser doesn't send a normal HTTP request. Next.js's client router intercepts the click and sends a special request:
GET /another-page HTTP/1.1
RSC: 1 ← Tell the server: Payload only, no HTML neededThe server sees RSC: 1 and returns only the Payload:
self.__next_f.push([1, "...complete component tree for the new page...\n"])The client React reconstructs the entire virtual DOM tree from this Payload (including all Server Component render output) and renders it. Since the Layout sits at a higher level and hasn't changed, React only replaces the Page subtree — hence instant navigation with Layout state preserved.
Initial load = HTML + Payload. Client-side navigation = Payload only.
We've seen Suspense fallback replacement and Client Component references — but here's a question: why does <button>❤️ 42</button>'s DOM arrive in chunk 1, while <h1>Hello Alice</h1> waits until chunk 2?
The answer lies in how the Next.js renderer traverses the component tree: depth-first traversal that skips Suspense subtrees and continues to sibling nodes.
Renderer traversing the component tree (simplified):
<div> ← Enter
├── <p>Welcome to my blog</p> ← Server, no await → render → output DOM → continue
│
├── <Suspense fallback={<Loading/>}> ← Enter Suspense boundary
│ └── <SlowGreeting /> ← Async! Has await!
│ ← Returns Promise → skip this subtree
│ ← Output <template> placeholder + fallback → continue to siblings
│
└── <LikeButton likes={42} /> ← Client Component
← No await → render → output DOM + Payload reference
← Also inject <link rel="preload"> into <head>The key insight: Suspense only makes the renderer skip its own subtree — it doesn't stop the entire traversal. Sibling nodes — Client Components included — are processed normally.
By the time the first chunk arrives, the renderer has completed one full pass over the entire tree. The chunk contains:
First chunk (covers the entire tree):
├── Complete DOM for all non-Suspense components
├── All Client Component references ($L1/$L2/...)
├── All Client Component JS <link rel="preload"> tags
├── The first complete Payload for the component tree
└── Suspense subtree fallbacks (skeleton screens)
Subsequent chunks (only fill Suspense gaps):
├── Real DOM for previously-skipped subtrees
├── $RC swap scripts
└── Incremental Payload for those subtreesThe first chunk is the main payload — everything after is just filling Suspense holes. No new Client Component references, no new preloads, no new page structure. That's why even with Suspense on the page, the skeleton and main content appear simultaneously — only the truly dependent parts are delayed.
Combining all scenarios, a Next.js HTTP streaming response contains exactly four things:
═══════════════════════════════════════════════
1. Real DOM (browser renders directly)
═══════════════════════════════════════════════
<p>Welcome to my blog</p> ← Server Component
<button>❤️ 42</button> ← Client Component
═══════════════════════════════════════════════
═══════════════════════════════════════════════
2. <template> placeholder + fallback (Suspense unresolved)
═══════════════════════════════════════════════
<template id="B:0"></template> ← Invisible marker
<p>Loading name...</p> ← Fallback, user sees this
═══════════════════════════════════════════════
═══════════════════════════════════════════════
3. $RC swap script (Suspense resolved)
═══════════════════════════════════════════════
<div hidden id="S:0"><h1>Hello Alice</h1></div>
<script>$RC("B:0","S:0")</script>
═══════════════════════════════════════════════
═══════════════════════════════════════════════
4. self.__next_f.push — RSC Payload
═══════════════════════════════════════════════
<script>
self.__next_f.push([1, "...component tree data..."])
</script>
═══════════════════════════════════════════════All four are interleaved across chunks, regardless of whether the component is Server or Client, regardless of nesting depth. The only distinction: in the Payload, Server Components store render output ("$","h1",...), while Client Components store reference numbers and JS file paths ($L1 → abc.js).
Open your blog page (or any Next.js page), DevTools → Network:
<template id="B: — find the Suspense placeholderself.__next_f.push — find the RSC Payload$RC — find the swap script<Link> within the site, check the new request's Request Headers for RSC: 1Core concepts:
renderToReadableStream instead of renderToString — the former outputs chunks incrementally; encountering await doesn't block the entire tree$L1 → JS file), and plain data (props)'use client' does not prevent server rendering — it only causes the component's source to be bundled into a separate JS chunk, reachable via a reference number in the PayloadRSC: 1 header)<template> placeholder+fallback, $RC swap scripts, self.__next_f.push PayloadKeywords: RSC Payload, Flight protocol, renderToReadableStream, Streaming, $RC, Chunked Transfer, RSC:1, hydration comparison, 'use client' boundary
The remaining articles in this series are ordered from fundamental to high-level:
The six special file types (layout, page, template, error, loading); their rendering hierarchy; [slug] dynamic routes; generateStaticParams.
The 'use client' module graph boundary; interleaving Server Components as children of Client Components; Context Provider bridging.
HTTP chunked transfer encoding in detail; how <template> placeholders work; the static shell principle; the push-down pattern.
Viewport-based prefetch scheduling via Intersection Observer; static vs. dynamic route prefetching; RSC: 1 client-side navigation; useLinkStatus.
ISR lifecycle with revalidate; request-level deduplication with cache(); client-side cache via staleTimes; on-demand revalidation.