Two renderers,
one request
An App Router page is rendered twice, by two different programs, into two different wire formats, over one HTTP response. Neither of them produces a string. Both produce a stream whose chunks arrive in the order the work finishes — not the order the markup was written.
The name renderToReadableStream is exported by two separate packages, and they do unrelated jobs. react-server-dom-webpack/server exports the one that serializes a React tree into the Flight payload. react-dom/server exports the one that renders a React tree into HTML. Next.js produces both on a single request, but they are not one pipeline. Flight renders the component and data model; Fizz renders the HTML the browser paints. Fizz never reads Flight rows — a decoder sits between them, rebuilding an element tree from the wire.
Everything on this page was measured, not recalled. The traces below came out of React 19.3.0 running in a container, and the probe scripts are printed alongside their output so you can re-run them.
That inversion is the whole argument. A stream is not a slower array — it is a structure whose ordering is determined by completion rather than by position. Every mechanism on this page exists to make that safe: forward references in the Flight rows, placeholder comments in the HTML, and a tiny inline runtime that moves DOM nodes into place after the fact.
The protocol underneath is the one you already know
Before any React machinery, the shape. A ReadableStream is consumed through a reader, and the reader's read() returns a promise for exactly the object an iterator returns.
const rs = new ReadableStream({ start(c){ c.enqueue('a'); c.enqueue('b'); c.close(); }});const rd = rs.getReader();await rd.read();function* g(){ yield 'a'; yield 'b'; }const it = g()[Symbol.iterator]();it.next();
Promise<{value, done}>. Not {value, done} wrapped in a promise by convenience — this is the async iterator result contract.The measured output makes the equivalence literal:
READER {"value":"a","done":false} {"value":"b","done":false} {"done":true}ITERATOR {"value":"a","done":false} {"value":"b","done":false} {"done":true}async-iterable? function
That last line matters for the rest of the page. ReadableStream.prototype[Symbol.asyncIterator] is a function in Node 22, so a Flight or HTML stream can be driven by for await…of directly. Browser support is still uneven, which is why React's own code paths use read() loops rather than for await.
Where this sits relative to the async series
If you have read From Loop to Iterator to Stream, this is the same ladder one rung higher: the middle now holds bytes instead of values, and the party that moves it is the network. A ReadableStream is an async iterator with a queue, a high-water mark, and a cancellation channel bolted on.
Flight: serializing a tree into rows
The Flight renderer does not produce HTML. It produces a newline-delimited sequence of numbered rows, each one a fragment of a serialized React tree. Its output content type is text/x-component.
The format is deliberately flat. A React tree is a graph, and a graph cannot be streamed as a nested literal — you would have to finish the deepest child before you could close the root. Flight solves this by emitting the root immediately with forward references to rows that do not exist yet, then emitting those rows as their work completes.
The row grammar
Every row is id:payload\n, where id is a hexadecimal chunk number. The payload is JSON, except that strings beginning with $ are escape sequences. This is the full set as measured from a single render that deliberately contained one of every type:
1:[["k",1]]2:[1,2]4:o3,\x01\x02\x030:{"str":"plain","num":42,"bool":true,"nul":null, "undef":"$undefined","big":"$n10", "date":"$D2026-01-02T03:04:05.000Z", "map":"$Q1","set":"$W2","arr":[1,"two"], "inf":"$Infinity","nan":"$NaN","neg0":"$-0", "dollarEscape":"$$literal","promise":"$@3","typed":"$4"}3:"resolved"
$Q Map, $W Set, $D Date, $n BigInt, $@ promise. A literal leading $ is escaped by doubling it.o, byte length 3, then the bytes themselves. Bytes 0x01 0x02 0x03 have no printable form, so they are shown here as escapes — on the wire they are three raw bytes, not twelve characters.| JavaScript value | Wire encoding | Mechanism |
|---|---|---|
undefined | "$undefined" | JSON has no undefined; a sentinel string stands in |
10n | "$n10" | BigInt, decimal digits after the tag |
new Date(…) | "$D2026-01-02T…" | ISO string after the tag |
new Map([…]) | "$Q1" | Reference to row 1, which holds the entry array |
new Set([…]) | "$W2" | Reference to row 2, which holds the member array |
Infinity / NaN / -0 | "$Infinity" "$NaN" "$-0" | Values JSON cannot round-trip |
Uint8Array | "$4" → 4:o3,\x01\x02\x03 | Binary row: tag o, byte length 3, then the raw bytes. Bytes 0x01 0x02 0x03 have no printable form, so they are shown as escapes; on the wire they are three raw bytes |
a Promise | "$@3" | Row 3 is written when the promise settles |
| a pending element | "$L2" | Lazy reference — row 2 arrives later |
Symbol.for('react.suspense') | "$Sreact.suspense" | Registered symbol by key |
the literal string "$literal" | "$$literal" | A leading $ is escaped by doubling |
Read the row numbers, not the row order
Rows 1, 2 and 4 were written before row 0, even though row 0 is the root. The serializer walks the root, and each time it meets a value that needs its own row it emits that row first, then continues. Row 3 — the promise — could not be written until it settled, so it came last. Arrival order carries no structural meaning; only the ids do.
An async component is a row that has not been written yet
A Server Component may be an async function. The Flight renderer awaits it. What it does not do is wait for it before emitting the parent — if that component sits under a Suspense boundary, the parent ships immediately with a $L reference in the child slot.
async function Slow({label, ms}){ return <p>{await sleep(ms, label)}</p>;}function App(){ return ( <main> <h1>shell</h1> <Suspense fallback={<i>fb B</i>}> <Slow label="B" ms={60}/> </Suspense> <Suspense fallback={<i>fb A</i>}> <Slow label="A" ms={20}/> </Suspense> </main>);}
+ 78ms 1:"$Sreact.suspense"+ 82ms 0:["$","main",null,{"children":[ ["$","h1",null,{"children":"shell"}], ["$","$1",null,{"fallback":…,"children":"$L2"}], ["$","$1",null,{"fallback":…,"children":"$L3"}]]}]+ 99ms 3:["$","p",null,{"children":"A-payload"}] ← second in source+138ms 2:["$","p",null,{"children":"B-payload"}] ← first in source
- Row 0 is written at 82ms. It is not the finished root: it is the root as far as it can be serialized at that moment, with each pending region standing in as a reference.
- Row 3 arrives at 99ms. Row 2 arrives at 138ms. Row 3 precedes row 2 on the wire.
- Nothing downstream has to care, because
$L2and$L3named their targets by id when the shell was written.
Development builds emit rows production builds do not
Running the same probe without NODE_ENV=production adds D rows carrying component names, source file positions, owner stacks and timing — the data behind the React DevTools server timeline. They are real rows in the same grammar, but they are a debugging channel, and reading them as part of the protocol is a common way to end up describing a format that never reaches production.
Fizz: the same inversion, a different wire format
The HTML renderer faces the identical problem and solves it differently, because its consumer is an HTML parser that cannot be told to hold a slot open. So Fizz writes the fallback into the document at the correct position, then patches the DOM later using script tags.
[+ 1ms] the shell — everything not inside a boundary<main> <h1>shell</h1> <!--$?--><template id="B:0"></template> <i>loading B</i> <!--/$--> <!--$?--><template id="B:1"></template> <i>loading A</i> <!--/$--></main>[+ 8ms] B:1 completes first — it is the 20ms child<div hidden id="S:1"><p>A-html</p></div><script>$RB=[];$RV=function(a){ … }</script>[+47ms] B:0 completes last — it is the 60ms child<div hidden id="S:0"><p>B-html</p></div><script>$RC("B:0","S:0")</script>
? marks it unresolved; <!--$--> would mark it already complete. The closing <!--/$--> ends it.S:0 into the boundary anchored at B:0, then delete the husk. The second boundary costs 68 bytes because the runtime is already present.Boundary B:1 — the one written second — completes and flushes first, exactly as its Flight counterpart did. They invert for the same reason, not from a shared mechanism: each orders its output by completion rather than by document position. The two renderers cannot see each other at all, as the next section shows.
How Next.js joins them: two products, one response
On a document request, Next.js needs the Flight payload for two unrelated purposes at the same time — and a stream can only be read once.
- The HTML renderer needs the tree those rows describe — it never reads the rows itself.
createFromReadableStream, the client half of the RSC package running on the server, rebuilds an element tree, and that tree is what Fizz renders. - The same bytes must be inlined into the HTML as
self.__next_f.push(…)script tags, so the browser can rebuild the tree without a second network round trip.
The resolution is tee(), which turns one stream into two independent readers backed by a shared source.
The two renderers are not merely separate functions. They resolve React through different export conditions, and neither will load in the other's environment. Measured in plain Node, both directions fail:
$ node --conditions react-server -e "import('react-dom/server.edge')" react-dom/server is not supported in React Server Components.$ node -e "import('react-server-dom-webpack/server.edge')" The "react" package in this environment is not configured correctly. The "react-server" condition must be enabled in any environment that runs React Server Components.
Next.js gets both in one process by building two module graphs — bundler layers, each resolving its own copy of React. A Server Component never executes in Fizz's graph, and Fizz never executes in the Flight graph. The only thing that crosses between them is bytes.
tee() has no backpressure between branches
If one branch is read and the other is not, the unread branch's queue grows without bound — the source is pulled at the rate of the faster consumer, and everything the slow branch has not taken is held in memory. Measured directly: draining the fast branch of a five-chunk stream to completion leaves all 5 chunks buffered for the slow branch, which then yields all 5 from that buffer. On a real page the branches move at similar rates, but this is the mechanism behind memory growth when one side stalls.
Backpressure: who decides how fast the render goes
A ReadableStream does not push. It is pulled, and the queue between producer and consumer has a declared capacity. When that capacity is full, the producer is not called again until space appears.
const bp = new ReadableStream({ start(c) { log(`start desiredSize=${c.desiredSize}`); }, pull(c) { log(`pull desiredSize=${c.desiredSize}`); c.enqueue(1); },}, new CountQueuingStrategy({ highWaterMark: 3 }));
enqueue always succeeds — but the level at which the stream stops asking for more.0 is the signal to stop; it can go negative if the producer ignores it.start desiredSize=3pull desiredSize=3 -> enqueuepull desiredSize=2 -> enqueuepull desiredSize=1 -> enqueue--- reader reads once ---pull desiredSize=1 -> enqueue
Three pulls fill the queue, then silence. One read() frees one slot and produces exactly one more pull. That loop is what connects a slow client to a slow render: TCP backs up, the response's stream stops being drained, desiredSize sits at zero, and React stops being asked for chunks.
const slow = new Writable({ highWaterMark: 64, write(chunk, enc, cb) { setTimeout(cb, 20); },});const ok = slow.write(Buffer.alloc(48));if (!ok) { await once(slow, 'drain');}
desiredSize is — ignoring it is legal, and unbounded.pipe() exists so you never write this loop yourself.write #1 -> truewrite #2 -> false false: buffered 96B of 64B, awaiting drain drain fired, buffered now 0Bwrite #3 -> truewrite #4 -> false
A 48-byte write into a 64-byte buffer succeeds; the second pushes the buffered length to 96B and
write returns false. Nothing stopped it — the producer is trusted to wait for
drain, just as a Web producer is trusted to watch desiredSize.
desiredSize fall to zero and the producer go quiet.React's own queue is not this queue
Fizz maintains its own internal buffer of completed work and decides when to flush it into the stream. Backpressure on the outbound stream slows the flush, which slows how fast completed boundaries leave the process — it does not pause the rendering of components that are already in flight. A stalled client therefore costs memory in the React scheduler before it costs anything in the stream's queue.
One stream, two consumption strategies
The distinction between a statically generated route and a dynamically streamed one is not a different renderer. It is the same stream, read differently.
// stream it: take the first chunk and send it onconst s1 = await renderToReadableStream(tree());const first = await s1.getReader().read();// → first bytes at +11ms (203 bytes)// buffer it: wait for everythingconst s2 = await renderToReadableStream(tree());await s2.allReady;// → resolved at +53ms
allReady and discards the streaming property it just paid for.| Streaming (dynamic) | Buffered (prerender) | |
|---|---|---|
| Consumption | read chunks as they arrive | await allReady, then read |
| First byte | +11ms | +53ms |
| Fallbacks in output | yes, then patched by $RC | no — boundaries already resolved |
| Errors mid-render | already-sent bytes cannot be recalled | can fail the whole build cleanly |
| Where it runs | per request | build time, or on revalidation |
The row about errors is the sharp one. Once the shell has been written to the socket, the status code is fixed and the <head> is gone. A boundary that throws afterwards can only be reported inside the body, which is why streaming renders surface errors as client-side boundary failures rather than as a 500.
Web streams and Node streams are both supported ends
React ships separate entry points rather than an adapter, because the two stream families differ in control flow, not just in naming.
| Web / Edge | Node | |
|---|---|---|
| Entry point | react-dom/server.edge | react-dom/server.node |
| Function | renderToReadableStream | renderToPipeableStream |
| Returns | a ReadableStream | an object with pipe and abort |
| Direction | consumer pulls | you push into a Writable |
| Shell signal | the returned promise resolves | onShellReady callback |
| Cancellation | AbortSignal via signal | abort() on the returned object |
| Backpressure | desiredSize / pull | write() returning false, then drain |
NODE renderToPipeableStream chunk writes: +9ms 203B ← shell +11ms 897B ← boundary A + the inline runtime +51ms 68B ← boundary B, runtime already presentReadable.toWeb / Readable.fromWeb: function function
import { renderToReadableStream } from 'react-dom/server.edge';const stream = await renderToReadableStream(tree, { signal: req.signal, onError(err) { report(err); },});return new Response(stream, { headers: { 'content-type': 'text/html' },});
import { renderToPipeableStream } from 'react-dom/server.node';const { pipe, abort } = renderToPipeableStream(tree, { onShellReady() { res.setHeader('content-type', 'text/html'); pipe(res); }, onShellError(err) { res.writeHead(500); }, onError(err) { report(err); },});
pipe, because writing the first chunk commits them.onError precisely because this is the only failure that happens before any byte is on the wire.write() returning false followed by a drain event, which pipe handles internally.The byte counts are the interesting part: 897 bytes for the first completed boundary against 68 for the second. The runtime is amortised across the whole document, so a page with twenty boundaries pays for $RC once.
Readable.toWeb and Readable.fromWeb exist for crossing the boundary, and Next.js uses them where a Node HTTP response has to carry what a Web-stream renderer produced. They are not free — each adds a queue and a copy — which is part of why the Edge runtime avoids the round trip entirely.
How cookies() reaches a component nobody passed anything to
An async Server Component nested six levels deep can call cookies() with no props and no imports carrying request state. The mechanism is AsyncLocalStorage, which propagates a store across await boundaries through V8's async context tracking.
const als = new AsyncLocalStorage();async function deep(n){ await sleep(1); return n ? deep(n - 1) : als.getStore().reqId;}await als.run({ reqId: 'req-7f3a' }, () => deep(5));// → 'req-7f3a' (five awaits deep)als.getStore();// → undefined (outside the run scope)
fn inherits the store — including the continuations scheduled by each await.undefined outside any run, which is the error Next.js reports when a request API is called from module scope.This is the same async-context machinery async_hooks exposes, and the reason calling a request API from module scope fails with a clear message rather than silently returning another request's data. It is also why Next.js can treat a call to cookies() as the signal that a route must be dynamic: the call is observable at exactly the moment the store is read.
The client rebuilds the tree from a global array
The response carries both products, so the browser ends up with two independent consumers of the same document: the HTML goes to the parser and becomes DOM, and the Flight rows go to a decoder and become React elements. In the browser, as on the server, neither is derived from the other.
The client half of the story starts with a problem of its own: row data arrives while the bundle that knows how to read it is still downloading.
The inlined half of the tee lands in the document as script tags that push into self.__next_f. The trick is that the array exists before the framework does.
<script> self.__next_f = self.__next_f || [];</script><script> self.__next_f.push([1,'1:"$Sreact.suspense"\n'])</script><script> self.__next_f.push([1,'0:["$","main",…]\n'])</script>… framework bundle loads and replaces .push …<script> self.__next_f.push([1,'3:["$","p",…]\n'])</script>
push for a function that feeds a stream controller, then replays everything already in the array. A buffer is promoted into a stream without losing anything.1 is a tag for the row-data channel; other tags carry bootstrap instructions.The client's parser is createFromReadableStream. It maintains a map of chunk id to a promise-like record, and every $L or $@ reference becomes a lazy node resolved against that map. React then renders the tree normally, suspending wherever a referenced chunk has not arrived — which is how a boundary in the payload becomes a real Suspense boundary in the client tree.
Two independent repair mechanisms are running
$RC patches the DOM for users who have not hydrated yet, moving server-rendered HTML into place with no React involvement at all. __next_f feeds the React tree for hydration and for anything rendered afterwards. They describe the same boundaries and are deliberately redundant — one works with JavaScript disabled mid-load, the other survives navigation.
Partial prerendering: pausing a render and finishing it later
Everything above assumes one render, start to finish. Partial prerendering splits it in two across time: a static shell produced at build, and the holes filled at request time.
- At build, the tree is rendered with a
prerenderAPI. When a component reads request data it does not abort — it postpones, and the renderer records enough state to continue from that point. - The output is a static shell plus a serialized postponed state. The shell goes to the CDN.
- At request time
resumetakes the postponed state and renders only the holes, streaming them into the already-delivered shell using the same$RCmechanism.
This is the strongest version of the argument the whole page has been making. A render that can be serialized at a suspension point, stored, and continued in a different process is only possible because the output was never a string — it was always a sequence of independently addressable pieces.
Version-sensitive surface
PPR's public API has moved more than the rest of this page. In Next 15 it sits behind experimental.ppr; in Next 16 the surrounding model is reorganised around explicit caching directives. The underlying postpone-and-resume mechanism has been stable, but treat the configuration names here as something to check against the release you are on rather than as settled.
What this part deliberately left out
Scope boundaries, so the next parts have somewhere to start:
| Part | Subject | Picks up |
|---|---|---|
| 1 | App Router and Server Components (this page) | — |
| 2 | Request and response machinery | How the Web Request maps onto Node's IncomingMessage |
| 3 | Route Handlers and streaming responses | ReadableStream.from, async generators as response bodies, SSE |
| 4 | Data fetching and the cache | The patched fetch, deduplication, revalidation |
| 5 | The build pipeline | Where the client manifest that Flight needs comes from |
| 6 | Client router internals | RSC-only navigations, prefetch, Next-Router-State-Tree |
| 7 | Edge and Node runtimes | What actually differs beneath the two entry points |