// react server components — data flow visualised

Full Data Flow
Summary

Every step from browser request to interactive app — the pipeline, the payload, the dual renderers, and the APIs that wire it all together.

View Pipeline ↓ Jump to APIs
01 — The Full Pipeline

Browser Request → Interactive App

Three distinct phases. Two renderers. One continuous pipeline. Each phase hands off to the next — the output of one is the input of the next.

Phase 1
Server: RSC Render + HTML Render
⚙
Server Component
Runs async operations
Executes await db.query(), file reads, env secrets. Its implementation never ships to the browser; only its rendered output and references do.
🧬
RSC Renderer
Serializes → Flight Payload
Resolved data becomes serialized props. Client Component boundaries become $L client references. Server Functions / Actions become opaque $F server references.
🖨
React DOM Server
Produces → HTML
Uses the renderable server result to emit paintable HTML. Server Component output becomes markup; Client Component boundaries may get initial HTML, but no event handlers yet.
HTML + inlined Flight (one streamed response)
Phase 2
Browser: Critical Render Path → FCP
📄
HTML Parser
Builds DOM Tree
Parses HTML bytes into DOM nodes. Async JS chunks can load later, so the browser can build and paint the server-rendered DOM before hydration finishes.
🎨
CSS → CSSOM
Render-blocking
Stylesheet parsed into CSSOM. Combined with DOM to create Render Tree. Layout → Paint → Composite.
⚡
FCP
First Contentful Paint
User sees useful content: the shell, ready Server Component output, and any Suspense fallbacks for regions still waiting. Buttons may be visible — but not interactive yet.
Async JS bundles load + execute
Phase 3
Browser: hydrateRoot() → TTI
📡
Flight Consumed
Imports client chunks
React consumes the inlined Flight data. Client references such as $L1 and $L2 resolve through the bundler into the JavaScript chunks needed for hydration.
🌳
Reconstruct React Model
Hydration model in memory
Server Component output is represented as data. Client Component slots load real modules and receive their props from Flight.
🔗
Reconcile + Attach
Walks DOM node by node
Matching DOM nodes get event listeners attached in place. No full DOM rebuild is needed; React adopts the existing HTML and the app becomes interactive — TTI reached.
01a — The Exact Order

What Runs in Sequence, What Overlaps, What Feeds What

One request starts one server render. That render gives the browser two things it needs: HTML for what should be painted now, and Flight data for the React tree, props, suspended regions, and client references that describe that UI. They can stream at overlapping times, but they are not unrelated outputs and they are not mirror copies of each other.

1
RSC render
react-server-dom-webpack/server · renderToReadableStream
inyour Server Component tree→outFlight stream
Executes your Server Components, awaits their data, and serializes the output into Flight as it goes — execution and serialization are one streaming pass, not "resolve the whole tree, then serialize it." Parts that are ready emit their rows now; a Server Component still awaiting data emits a placeholder and its rows stream later. Client Components emit as $L references — they do not execute during the RSC render. They may still be pre-rendered later by the HTML render, and they run in the browser during hydration.
SERVER · RSC RENDER component tree ▸ main ├ h1 · article ├ Comments (async) └ Like (client) RSC renderer run · await · serialize FLIGHT (streamed) 0: ["$","main", …] 2: $L → Like 3: $S Comments (placeholder) … comment rows (later) one streaming pass — execute Server Components and serialize as it goes (not resolve-then-serialize)
⟲ steps 1 & 2 overlap in time They are pipelined: React DOM Server can flush HTML for regions whose server-rendered React result is ready while slower regions are still waiting. The overlap is in timing, not in dependency. Ready regions can become HTML now; missing regions wait, show a fallback, or stream in later.
2
SSR HTML render
react-dom/server · renderToPipeableStream · consumes the renderable RSC result
inrenderable RSC result→outHTML stream
Uses the server-rendered React result to produce HTML for everything that can be painted: normal Server Component output, placeholders for pending boundaries, and initial markup for Client Component boundaries where possible. Client Component interactivity is not attached here. It flushes the shell first, then streams each Suspense boundary's HTML plus an inline $RC script as that boundary resolves.
SSR · HTML RENDER renderable result 0: ["$","main",…] 2: $L → Like 3: $S Comments react-dom/server HTML shell + fills HTML OUT (browser) <main> … <button>♥ 128</button> ⏳ fallback: "Loading comments…" ↳ $RC swaps in real HTML use the renderable server result → flush shell first, then stream each Suspense boundary as it resolves
3
One response — HTML plus inlined Flight
The first load usually carries both the paintable HTML and the Flight data React will need for hydration and later React work:
ONE RESPONSE · TWO USES server result RSC / Flight result one render ① SSR render → HTML the browser paints ② inlined as <script> → browser keeps it for hydration
use 1 · paint
The HTML stream tells the browser what DOM to paint before JavaScript is ready.
use 2 · React hydration
Flight data is inlined into the HTML as <script> chunks (e.g. self.__next_f), interleaved with the markup — so React can hydrate Client Component boundaries and reuse the server tree description.
4
Browser — paint, then hydrate
inHTML + inlined Flight→outinteractive UI
Paints progressively (shell → boundaries fill via $RC) — not interactive yet. Once the client bundle loads, React re-reads the inlined Flight (createFromReadableStream), $L references import their chunks, and event listeners attach in place. → TTI.
BROWSER · PAINT → HYDRATE ▲ FCP ▲ TTI paint shell visible, not interactive fill comments $RC swaps fallback out hydrate → live listeners attach in place paint the shell (FCP) → fill streamed parts via $RC → hydrate to interactive (TTI)
the one rule HTML is downstream of the server-rendered React result. Flight describes that result for React; HTML turns the renderable parts into pixels for the browser. They can overlap in time, but they are not independent — and Server Components are not missing from the HTML.
what this corrects
✗Server Components resolve into a complete element tree, then a separate step serializes it.
✓Render and serialize are one streaming pass — Flight rows stream out as each Server Component resolves.
✗HTML and Flight are produced in parallel, neither derived from the other.
✓React DOM Server produces HTML from the renderable server result. Server Component output can become HTML; Client Component boundaries may also get initial HTML before hydration.
✗The browser gets HTML; the Flight payload is a separate request.
✓On first load it's usually one streamed response — visible HTML plus inlined Flight data for React.
01b — The Hard Parts

Straight Answers to What Usually Gets Hand-Waved

The overview above moves fast, and a few steps quietly assume things that, left unsaid, leave you guessing — where Server Components show up in HTML, what Flight is for, how a reference turns into an import, and what hydration actually replays. Here's the precise version of each.

Q1
Are Server Components missing from the HTML?

No. Server Components run on the server and their returned JSX is part of the server-rendered result. React DOM Server uses that renderable result to emit HTML, so normal Server Component output can absolutely become visible markup. What does not ship is the Server Component's JavaScript implementation. Client Components are different: they appear as boundaries, may get initial server-rendered HTML, and later hydrate in the browser.

✗"React DOM Server" runs on the server → so it must be the thing that runs the Server Components.
✓Server Components run on the server, contribute renderable output, and can become HTML. Client Components add the extra step: initial HTML now, browser JavaScript and hydration later.
Q2
So there are two separate server environments?

Yes — there are two related server-side concerns. The RSC/server layer executes Server Components and produces the Flight model: rendered output, serialized props, suspended regions, and client references. The HTML layer uses the renderable result to stream markup. React's word Client can be confusing here: it often means "a consumer of the server output," so the browser is a client, and the server HTML renderer is also consuming the RSC result.

Q3
Are the HTML and the Flight payload produced independently, from one shared tree?

The "Two Formats" picture is useful — but mechanically this is a pipeline, not two independent renderers printing from the same blueprint.

✗One blueprint → two renderers independently print at the same time; neither output depends on the other.
✓Server Components run first, and React records the result as Flight data. React DOM Server then uses the renderable result to emit HTML. They can overlap in time through streaming, but a region's HTML depends on that region's server-rendered React result being ready.
Q4
Then why send both the HTML and the Flight payload — isn't that the same data twice?

They overlap in meaning, but they answer different questions. HTML answers: "what should the browser paint now?" The Flight payload answers: "what React tree, props, suspended regions, and client references describe this UI?" The browser cannot reconstruct that React model from HTML alone, so React keeps the Flight data for hydration and later client-side navigations.

Q5
How does a $L lazy reference actually become an import()?

The $L2 sitting in the tree is a pointer to a row the renderer emitted right beside it. That row — 2:I[…] — is the bundler's client-manifest entry: the module's path, the chunk file(s) to fetch, and the export name. When React resolves the reference — lazily, only when that node is about to render — it hands the descriptor to the bundler runtime, which loads the chunk and returns the real component. $L = lazy; the I row = import this module.

in the Flight stream ["$","$L2",null,{postId:42}] // element: "my type is component 2" 2:I["./LikeButton.js",["like.4f3a.chunk.js"],"default"] // where component 2 lives ↓  on the client, React resolves the ref → import("./LikeButton.js")  →  <LikeButton />
Q6
Do Server Components re-run in the browser when the page hydrates?

No. Hydration is about wiring up interactivity — not replaying the whole tree.

✗Hydration re-renders the entire component tree on the client.
✓Only Client Components run in the browser — to rebuild their virtual DOM and attach event handlers in place. Server Components never ship and never run there. "Re-rendering" one means asking the server again (a navigation or refresh), which streams a fresh Flight payload.
Q7
Does "use client" mean "this code runs only in the browser"?

No — it's a boundary marker, not a runtime switch. It marks the entry point into the client module graph, so the bundler ships that module and emits a manifest entry for it. The code still runs on the server once during SSR to produce HTML, then again in the browser to hydrate. "Client" only distinguishes it from a Server Component — it does not mean the code skips the server.

three meanings of "run" RSC render: Client Components do not execute; they are recorded as client references. HTML render: Client Components may be pre-rendered to initial, inert HTML. Browser: Client Components execute to hydrate, attach handlers, and use state/effects.
Q8
Isn't RSC just a new name for SSR?

No — they're separate concerns, and most apps use both. SSR is the HTML renderer: it turns the renderable React result into markup for the first paint. RSC decides which components run only on the server and describes their result as Flight data. Together, they give you server-rendered HTML for visible UI and no browser bundle for Server Component implementations.

Q9
How does the server serialize the component tree — and what does it use to do it?

The Flight renderer serializes the Server Component result. It runs a real React server render — calling Server Components, awaiting async work, and recording the result as the Flight wire format: a stream of numbered rows. Plain serializable values are inlined; values that need special treatment get type-prefixed references. To encode a "use client" component it consults the bundler's client manifest — that's how the component becomes an I row (path + chunk + export) pointed at by $L. React DOM Server is the HTML side: it turns the renderable result into markup.

encoding the tree (conceptually) renderToReadableStream(<App/>, clientManifest) // the Flight renderer // internally ≈ JSON.stringify(tree, replacer) — non-JSON values get prefixes: Date → $D"2026-06-19T…" Promise → $@7 // filled in by a later row Set → $W… <LikeButton/> → $L2 + 2:I["./LikeButton.js",[…],"default"]
Q10
Where does createFromReadableStream come from, and how does it get into the browser?

It's the client half of the very same Flight package — createFromReadableStream from react-server-dom-<bundler>/client.browser (there are .edge and .node builds too, which the SSR pass uses). Your framework's client entry point imports it, so the bundler ships it inside your client JS bundle — that's literally how it reaches the browser. The Flight payload doesn't need a second request on first load: the framework usually inlines it into the HTML as <script> chunks (Next pushes onto self.__next_f; other frameworks use their own bootstrap shape). For later client-side navigations, the browser fetch()es a fresh Flight stream and pipes response.body straight in.

Q11
How does createFromReadableStream actually work?

It's a thin wrapper over the Flight client. Given the Flight ReadableStream, it reads the rows incrementally and returns a Thenable — a Promise-like value that resolves to the root React element tree. As chunks arrive it fills a row-keyed model: $L references resolve to client modules (loaded through the bundler runtime), $@ references resolve when their promise's row streams in, plain rows inline directly. Because the result is a Thenable, you hand it straight to React's use() / Suspense — so the tree can render progressively as it streams, instead of blocking on the whole payload. Server and client are mirror images:

the two halves of Flight server renderToReadableStream(tree, manifest) → Flight stream client createFromReadableStream(stream) → Thenable<tree> → use(tree)
Q12
Why a custom wire format — why not just JSON.stringify the tree?

Because plain JSON can't carry what a React tree needs.

✗The payload is just JSON.stringify(tree).
✓It's a JSON superset. JSON has no way to express a React element, a pending promise (async data that resolves later), a Set/Map/Date/BigInt, or a reference to a client module or another row. Flight adds $-prefixed types and a numbered-row stream so values can resolve over time — which is exactly what makes Suspense streaming possible.
Q13
How does a Server Action ($F) become a function the client can call — and POST back?

Same trick as a Client Component, run in reverse. At build time each "use server" function gets a hashed action id and is registered in a server map. On the client build the function is replaced by a tiny proxy — createServerReference(id, callServer) — so what the Client Component actually receives is a callable stub. In the payload it travels as a server reference: {$$typeof: Symbol.for("react.server.reference"), $$id: "…"} (the $F row). Calling the stub runs callServer(id, args): it encodeReply(args) (the same rich serializer the payload uses) and POSTs to the server with the action id in a header. The server looks the id up, decodeReplys the args, runs the function, and streams back a fresh Flight payload — the re-rendered tree plus the return value — decoded with the same createFromReadableStream. (With <form action={fn}> it degrades gracefully: no JS → the browser just POSTs the form, handled server-side by decodeAction.)

client → server → client // client build: the action is a proxy, not the real function proxy = createServerReference("a3f9…", callServer) proxy(args) → callServer(id, args) → fetch(POST, { headers:{ "rsc-action": id }, body: encodeReply(args) }) ↓ on the server decodeReply(body) → run action → renderToReadableStream({ root, returnValue }) ↓ back on the client createFromReadableStream(res) → update UI + returnValue
Q14
What does the bundler's "use client" scan actually produce?

Two things, in two passes. When a Server Component imports a "use client" module, the server build does not include that component's code — it replaces it with a client reference: {$$typeof: Symbol.for("react.client.reference"), $$id: "<module-id>"} (via registerClientReference). That's exactly why calling a Client Component from the server throws — there's nothing to call, only a reference. The client build does a separate pass that compiles the real component against the browser runtime and code-splits it into a chunk. The piece that ties them together is the client manifest the bundler emits — a map from each reference id to its file:

react-client-manifest.json — the bridge { "./LikeButton.js": { // ← the reference's $$id is the key "id": "./LikeButton.js", "chunks": ["like.5c2a.chunk.js"], "name": "default" } } → handed to renderToReadableStream(tree, manifest); each reference becomes an I row

The directive also marks that module and all its transitive imports as client code — and the real module is compiled for the browser bundle. During the RSC render, the server sees a reference; during the HTML render, the component may be pre-rendered to inert markup.

Q15
How does one react package become two different runtimes?

Through Node/bundler export conditions. React's package.json ships a react-server entry in its exports map. Code resolved under that condition — the bundler's "react-server" layer, or Node's --conditions react-server — gets a different build of React: one with the Server Component machinery and without client-only APIs like useState / useEffect. Everything else (Client Components, the SSR pass, the browser) resolves the normal build.

✗Server and Client Components import the same react.
✓The bundler builds two module graphs. import { useState } from "react" resolves to a hook-less build in the server layer and the full build everywhere else — same specifier, two runtimes. That's why a hook in a Server Component is a build-time error, not a runtime one.
Q16
How does a Suspense boundary stream in — the placeholder-then-fill trick?

A boundary that isn't ready doesn't block the response — it ships a placeholder now and patches in the real content later, on both streams in parallel.

HTML side (Fizz): the server flushes the shell with the fallback inside boundary markers — <!--$?--> (pending) … <!--/$--> plus a <template id="B:0"> anchor. When the data resolves it streams the real HTML into a hidden div, then an inline script calls React's $RC ("complete boundary") to swap the fallback for the content. React/frameworks may batch or schedule reveals so a burst of resolutions does not flash jarringly.

Flight side: the pending child is a promise; the serializer (serializeThenable) emits it as a $@ reference and keeps the stream open. When it resolves, a later row with that id streams in and the client swaps the fallback.

HTML stream — fallback, then reveal // shell flushes immediately, boundary still pending: <!--$?--><template id="B:0"></template>Loading…<!--/$--> ↓ data resolves — a later chunk arrives <div hidden id="S:0"><RealContent/></div> <script>$RC("B:0","S:0")</script> // swap fallback → content Flight stream does the same: a $@7 promise row that fills in later
Q17
Those <script>self.__next_f.push(…)</script> tags in the page source — what are they?

They're how the App Router inlines the Flight payload into the first HTML document. The same Flight bytes React needs to hydrate ride along inside the text/html response, so the first load needs no second request for the payload — paintable markup and hydration data arrive in one stream.

The shape is a self-flushing queue — the same trick analytics snippets use for dataLayer.push. One early script creates the global array; every chunk after it is just a push, and because each inline <script> runs the instant the browser parses it, the chunks append in order as the document streams:

the bootstrap queue — emitted top-to-bottom as HTML streams <script>(self.__next_f = self.__next_f || []).push([0])</script> // ① create the queue <script>self.__next_f.push([1, "0:[\"$\",\"html\",…]\n"])</script> // ② a chunk of Flight rows <script>self.__next_f.push([1, "2:I[\"./Like.js\",…]\n3:…\n"])</script> // ③ more as boundaries resolve

Each push is a two-tuple [tag, data]. The tag is a small integer: 0 bootstraps the queue (no data); 1 — by far the most common — carries a string of Flight rows, the exact newline-separated numbered-row grammar from Q9 (0:, 2:I[…], $L, $@…). So the shell can flush before a slow Suspense boundary's rows even exist; they stream in as later push calls.

When the client bundle boots it upgrades the array: it replaces push with a function that feeds each chunk into an in-memory ReadableStream, which createFromReadableStream reads. Pushes that landed before the JS ran are already sitting in the array and get drained on init — so delivery never waits for the bundle, and the bundle never misses an early chunk. The same render, two transports for the same Flight:

one render · two ways to ship the same Flight GET /some-page Accept: text/html → 200 Content-Type: text/html // HTML + inlined self.__next_f pushes ↓ later client navigation — same Flight, no HTML wrapper GET /some-page?_rsc=a1b2 header: RSC: 1 → 200 Content-Type: text/x-component // raw Flight, no <script> tags
✗Those inline scripts are page JavaScript / app logic being shipped and parsed.
✓They're pure data delivery — each one just pushes a string onto a global queue; there's no program to analyse. It's also exactly what trips a strict Content-Security-Policy: inline pushes need 'unsafe-inline' or a per-request nonce.
Q18
What actually happens on a client-side navigation — does it refetch the whole page?

No new HTML document. The router issues an RSC request for the destination (Next adds a ?_rsc=… query and an Rsc: 1 header) and the server returns a Flight payload, not a page. Two things keep it cheap:

Partial rendering — only the route segments that change are re-rendered and sent; shared layouts above the change are preserved. /dashboard/settings → /dashboard/analytics re-renders only the leaf; the dashboard layout and the root <html> stay put.

Router cache — an in-memory client cache keyed by segment holds the payloads of visited and prefetched routes, so back/forward and revisits are instant. <Link> prefetches a route's payload as it scrolls into view.

The new Flight tree is reconciled into the existing one, so Client Component state outside the changed segment survives the navigation. This is the concrete form of "re-rendering a Server Component = asking the server again" from Q6.

a client navigation GET /dashboard/analytics?_rsc=a1b2 header: Rsc: 1 → Flight payload for the changed segment only (shared layout reused) → reconcile into the live tree (client state preserved)
Q19
Security footnote: why Flight decoding is an attack surface

Real. The Flight format is not just inert display data: decoding it resolves references and reconstructs values, so deserializing untrusted Flight-shaped input is a security-sensitive operation. The important direction is the server decoding the client's input — a Server Function / Action call POSTs an encoded body that the server decodes before running the referenced server code.

✗The RSC payload is inert data, like JSON — safe to parse anywhere.
✓Decoding can resolve references and trigger server-side logic. In React2Shell (CVE-2025-55182, CVSS 10.0, Dec 2025), a flaw in server-side deserialization let crafted, unauthenticated requests reach remote code execution in vulnerable React Server Components setups.

Takeaway: keep React, your RSC framework, and your bundler integration patched according to their advisories. Treat every Server Function / Action argument as untrusted input: validate it, authorize it, and never assume that a client-provided payload is safe just because it targets server code.

02 — The Live Trace

Press Play: One Request, Frame by Frame

The same pipeline as an explorable. A single token travels four swim-lanes — Server, the two Renderers, the Network, and the Browser. Scrub it, step it, or click any node to crack open the real React internals and the Flight protocol behind that step.

Live — the round trip in one line
ArticlePage()
serialize
HTML + Flight
paint → FCP
hydrate → TTI
The pipeline — press play, scrub, or click any node for full detail
▶ GET /blog/slug · server → stream → paint → hydrate
00 / 13
FRAME 00 · —

Press play to trace the request

Each frame highlights as the request travels its lane. Blue and purple lanes are server-only — that code never ships. The cyan lane is the one HTTP response; the green lane is the browser turning bytes into pixels and then into an interactive app.

Server Component (event loop) Renderers — DOM & Flight Network — one HTTP response Browser — paint & hydrate Server Action (after TTI)
The pieces — click to open

Six parts do all the work

Every frame above is choreographed by a handful of pieces inside the React runtime. Click any card for the real APIs, the props rules, and the wire format it owns.

the through-line The server render produces two useful things, not two unrelated things. The RSC renderer records the Server Component result as Flight data; React DOM Server uses the renderable result to produce the HTML stream. HTML answers "what should be painted now?" Flight answers "what React tree, props, and client references describe this UI?" Client JS then hydrates the interactive boundaries.
02 — The Animated Flow

Visualised: Data Moving Through the System

Watch the data travel — from the SQL query to the browser screen — one step at a time, in the exact order each stage depends on the last. Press play, or step through it.

SERVER BROWSER REQUEST Browser GET /blog/slug REACT DOM SERVER renderToPipeable Stream() SERVER COMPONENT ArticlePage() await db.query() → resolves data RSC RENDERER Flight Payload 0: […tree…] 1: I{…} 3: F{…} HTML RECEIVED DOM + CSSOM → Render Tree MILESTONE FCP ⚡ User sees content FLIGHT CONSUMED createFrom ReadableStream() imports chunks HYDRATION hydrateRoot() reconcile DOM attach handlers NETWORK MILESTONE TTI ✦ HTML FLIGHT FCP → TTI gap USER INTERACTION — AFTER TTI CLICK EVENT ♥ Like button click OPTIMISTIC setLikes(n+1) UI updates instantly CRP REPAINT 1 layer only ~4ms to screen SERVER ACTION POST /_action SQL UPDATE Flight diff response → final reconcile 1 2 3 4 5 6 7 8 9 10 11
step 1 / 11
03 — One Server Render, Two Formats

The Same Server Render — as HTML and as Flight

One server render produces two useful forms. The HTML stream is the paintable form: what the browser can show immediately. The Flight payload is the React data form: the Server Component result, serialized props, suspended regions, and Client Component references React needs for hydration and navigation. Why this is a pipeline, not two unrelated prints →

① RSC renderer → Flight then / while streaming ▶ ② React DOM Server → HTML — one server render, two formats: paintable HTML and React Flight data
// shared source — the server-rendered UI result
ArticlePage — renderable server result (in memory)
The RSC renderer executes the Server Components and records the result as Flight data. React DOM Server uses the renderable result to stream HTML. So the two panels show one server-rendered UI in two forms: paintable markup on the left, React model data on the right. Hover a node to see both.
▸ main.article-page ▸ h1 "The Future of React" ▸ article [resolved HTML body] ▸ CommentSection {comments:[…]} ▸ LikeButton {postId:42, likes:128} ▸ likePost [Server Action]
Printer A
React DOM Server

Produces HTML — visual markup for the browser to paint. Server Component output is included; Client Component boundaries may receive initial markup before they hydrate.

<main class="article-page"> <h1>The Future of React</h1> <p class="byline">by Alice</p> <article>...body...</article> <!-- CommentSection shell --> <section> <button>Show 2 comments</button> </section> <!-- LikeButton shell --> <button>♥ 128</button> </main> <script>/* Flight payload */</script>
Node.js / Edge
VS
Printer B
RSC / Flight Renderer

Produces Flight payload — structured React tree data. Client Components become module references here, even if their initial HTML appears in the HTML stream.

0:["$","main",null,{"children":[ ["$","h1",null,{"children": "The Future of React"}], ["$","$L1",null,{"comments":[ {"id":1,"body":"Great post!"} ]}], ["$","$L2",null,{"postId":42, "onLike":"$F3"}] ]}] 1:I{"id":"./CommentSection"...} 2:I{"id":"./LikeButton"...} 3:F{"id":"./actions","name":"likePost"}
RSC Runtime
03b — Filling the Shell

Shells, and How They Get Filled

A shell is the HTML React can emit immediately — the page structure, with holes where something isn't ready. A hole waits on either data (a Suspense boundary) or JavaScript (a Client Component) — and each kind is filled by a completely different mechanism. Step through one response and watch both.

▌ one HTTP response, streamed over time
chunk 1 · the shellonShellReady
<main> <h1>…</h1> <article>…</article> <!--$?--><template id="B:0">skeleton</template><!--/$--> <button>♥ 128</button> // inert </main>
chunk 2 · resolved content$RC swap
<div hidden id="S:0"> <li>Great post!</li> … </div> <script>$RC("B:0","S:0")</script>
chunk 3 · the JS bundlehydrateRoot
// LikeButton.js, CommentSection.js hydrateRoot(document, <App/>)
myblog.dev/the-future-of-react
The Future of React
<h1> — painted instantly
Article body…
<article> — static server HTML
Suspense · waiting on data
@dan — Great post! ▸ reply
@sophie — This finally made shells click. ▸ reply
Client Component · waiting on JS ♥ 128 inert — no handler yet
Content fill — Suspense + $RC

The shell ships a fallback inside the hole. When the data resolves on the server, React streams the real markup as a <div hidden> plus a one-line script that relocates it into the slot. This runs before hydration — no component JavaScript needed to fill the content.

Behavior fill — hydration

A Client Component renders to static HTML — a shell that looks right but has no handlers. hydrateRoot walks that existing DOM, loads the component's chunk, and attaches listeners in place. The shell wakes up without being rebuilt.

04 — Flight Payload Anatomy

Every Row, Explained

Each line in the Flight payload has a row ID, a type prefix, and a content body. The type prefix tells the React Flight client how to interpret that row in this simplified example.

Type prefix legend
Common row shapes used in this simplified Flight example
0..n = Row ID I = Import F = Function (Action) $L = Lazy Ref
0
$
["$","main",null,{"children":[ ["$","h1",null,{"children":"The Future of React"}], ["$","$L1",null,{"comments":[{"id":1,"body":"Great!"}]}], ["$","$L2",null,{"postId":42,"onLike":"$F3"}] ]}]
Row 0 — Root Element Tree
The root model for this simplified example. "$" = React element. Intrinsic HTML tags are inlined as strings. DB results (title, comments) are fully resolved JSON props. Client Component slots are $L1, $L2 — lazy references to be loaded.
1
I
I{ "id":"./components/CommentSection.tsx", "chunks":["chunk-abc123"], "name":"CommentSection", "async":false }
Client Reference — "I" = Import
Points to the JS chunk file the client must dynamically import. The component's source code is NOT here. The client calls import("chunk-abc123") when it encounters $L1.
2
I
I{ "id":"./components/LikeButton.tsx", "chunks":["chunk-def456"], "name":"LikeButton", "async":false }
Second Client Reference
Separate chunk for LikeButton. On subsequent navigations, if this chunk is already cached, the browser reuses it — no re-download. This is why RSC navigation is fast.
3
F
F{ "id":"./app/actions.ts", "chunks":["server-ref-e5f6"], "name":"likePost", "bound":null }
Server Action — "F" = Function
The client gets an opaque callable handle. Calling onLike(42) fires a POST to /_next/action with this action ID. The actual SQL never ships to the browser.
4
$S
["$","$Sreact.suspense",null,{ "fallback":["$","p",null,{"children":"Loading comments..."}], "children":"$L5" }]
Suspense Boundary — "$S" = Special Type
Fallback renders immediately. The deferred child ($L5) streams in as a later chunk in the same HTTP response — no second round trip. The fallback slot is replaced in-stream.
Whole-tree view
pick a row to watch its full tree build · play / step
05 — Hydration Walkthrough

hydrateRoot() — Node by Node

After FCP, the async JS loads. React walks the server-rendered DOM and the client-side React model simultaneously — attaching handlers without touching the DOM.

#
DOM Node
React Virtual Node
Result
1
<main class="article-page">
Server Component output — server-rendered DOM. No handlers.
NO HYDRATION ✓
2
<h1> "The Future of React"
Text matches the server-rendered output. No event handlers needed.
MATCH ✓
3
<section> → <button>
CommentSection boundary
Client Component. useState(false) initialised. onClick={()=>setOpen(!open)} attached to existing <button> node.
HYDRATED ✦
4
<button> "♥ 128"
LikeButton boundary
Client Component. useState(128) → likes. onClick={handleLike} attached. Server Action $F3 wired to callServer handler.
HYDRATED ✦
All event listeners attached. All state initialised. React owns the DOM. hydrateRoot() complete.
TTI ✦
06 — The Serialization Boundary

"use client" — What Can Cross

The "use client" directive creates a serialization boundary. Props must be serializable — they are serialized through the Flight payload. Ordinary functions cannot cross. Server Functions / Actions cross only as opaque server references, not as function source code or closures.

RSC Serialization Boundary
Server side · stays put
🗄Database queries
🔑API keys / secrets
📁File system access
⚡Server Functions / Actions ("use server")
🧬RSC renderer logic
props
only
Client side · ships JS
⚛useState / useReducer
🖱Event handlers (onClick…)
🌐Browser APIs (window, document)
🔄useEffect / lifecycle
💫Animations / DOM refs
Serialization checkpoint tap a prop to send it across the boundary
SERVER CLIENT JSON only str
Pick a prop above and watch whether it can be serialized across the "use client" boundary.
07 — Key APIs Referenced

The Full API Surface

Every API involved in the RSC pipeline — from server-side streaming to client-side hydration to the module boundary directives.

Server — Node.js
renderToPipeableStream()
React DOM Server's primary HTML streaming API. Pipes to a Node.js writable stream. Fires onShellReady when above-the-fold content is ready — enabling fast FCP without waiting for all data.
Node.js
📡
Server — Edge Runtime
renderToReadableStream()
Web Streams API equivalent of renderToPipeableStream. Used in edge runtimes (Vercel Edge, Cloudflare Workers). Returns a ReadableStream compatible with the Fetch API Response.
Edge
🌊
Client
hydrateRoot()
The modern hydration API. Attaches React to server-rendered HTML by walking DOM and virtual tree in parallel. Attaches event listeners in place without rebuilding the DOM. Replaces deprecated ReactDOM.hydrate().
Browser
💧
Client (internal)
createFromReadableStream()
Consumes a Flight payload ReadableStream and reconstructs the React element tree. Resolves $L lazy references via dynamic import(). Returns a thenable React tree.
Browser
🧬
Client (internal)
createFromFetch()
Convenience wrapper around createFromReadableStream that accepts a fetch() Promise directly. Used during client-side RSC navigation to consume the new route's Flight payload response.
Browser
📥
Both
createServerReference()
Creates a callable client-side handle for a Server Action. When called, it invokes the callServer callback registered in createFromReadableStream, which POSTs to the server action endpoint.
Both
🔗
Module Directive
"use client"
Placed at the top of a module. Marks the serialization boundary. The RSC renderer emits an I{} Client Reference row for this module instead of inlining it. The module's code ships to the client bundle.
Directive
⚛
Module Directive
"use server"
Marks a function as a Server Action. The RSC renderer emits an F{} row — an opaque callable reference. The function's implementation stays on the server. Client receives only the action ID to POST to.
Directive
🖥
Back to JIT-Atlas