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.

The same tree, both renderers, same inversion
Two Suspense boundaries. B is written first and resolves in 60ms; A is written second and resolves in 20ms. Step through to watch both renderers ship A before B.

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.

probe/streams.mjs — reader vs iterator
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();
getReader()Takes an exclusive lock on the stream. While a reader is held, nothing else may read it — the same single-consumer constraint an iterator has.
read()Returns Promise<{value, done}>. Not {value, done} wrapped in a promise by convenience — this is the async iterator result contract.
Symbol.iteratorThe generator's iterator returns the same record synchronously. The two protocols differ in timing, not in shape.

The measured output makes the equivalence literal:

stdout
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.

A tree becomes rows with forward references
Click any row or node for its own breakdown.
$ mechanism — references and markers id row and boundary ids tag tag and type names key keys and attributes settled outstanding

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:

probe/types.mjs — output, NODE_ENV=production
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"
rows 1, 2 and 4 firstSub-values are written as the serializer meets them, so they land before the root that references them. Arrival order carries no structure.
the $ familyEvery value JSON cannot express gets a tag: $Q Map, $W Set, $D Date, $n BigInt, $@ promise. A literal leading $ is escaped by doubling it.
4:o3,\x01\x02\x03The binary row: tag 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.
3:"resolved" lastThe only row that had to wait. Everything else was synchronously available while the root was being walked.
JavaScript valueWire encodingMechanism
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\x03Binary 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.

probe/prod.mjs
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>);}
async function SlowReturns a promise for an element. The renderer treats it as a task, not as a value it must have before continuing.
SuspenseThe unit of streaming. Without a boundary here, the whole payload would wait for the slowest child — the boundary is what makes the parent shippable.
fallbackSerialized eagerly into the parent row, because it is what the client shows while the real row is outstanding.
ms={60} / ms={20}Written slow-first deliberately, so document order and completion order disagree.
stdout — timestamps are real
+ 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

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.

probe/fizz.mjs — output (indented for reading; the wire has no whitespace)
[+ 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>
<!--$?-->Opens a pending boundary. The ? marks it unresolved; <!--$--> would mark it already complete. The closing <!--/$--> ends it.
template id="B:0"The anchor. It carries no content and renders nothing — it exists so the runtime can find this boundary by id later.
div hidden id="S:1"Completed content, parked at the end of the document out of view. The parser appends it wherever it lands; position is fixed afterwards.
$RB / $RVThe inline runtime, written out in full on first use only. It is roughly 800 bytes and never repeats.
$RC("B:0","S:0")Move the children of 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.

Same hole, two encodings
Click either column's boxes to open its mechanism.

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 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:

probe — loading each renderer in the other's environment
$ 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.

One render, two consumers, one response
Every box opens a detailed breakdown of that stage.

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.

probe/streams.mjs — pull and desiredSize
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 }));
highWaterMark: 3The queue's declared capacity. Not a hard limit — enqueue always succeeds — but the level at which the stream stops asking for more.
desiredSizeCapacity minus queue length. Reaching 0 is the signal to stop; it can go negative if the producer ignores it.
pull(c)Called only when there is room. This is the inversion that makes backpressure automatic — the consumer's pace sets the producer's pace.
stdout
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.

probe/nodebp.mjs — write() and drain
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');}
highWaterMark: 64The same declared capacity, counted in bytes rather than chunks. Node streams have always had this; only the spelling differs.
write() returns falseThe write still succeeded. The boolean is advisory in exactly the way desiredSize is — ignoring it is legal, and unbounded.
'drain'Fires when the buffer empties. Web streams make you poll a number; Node pushes you an event. pipe() exists so you never write this loop yourself.
stdout
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.

The queue between the renderer and the socket
Watch 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.

probe/rest.mjs — measured on identical trees
// 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
allReadyA promise on the returned stream that settles when every boundary has completed. Awaiting it converts a streaming render into a batch one.
+11ms vs +53msSame tree, same work, same total cost. The difference is entirely in when the first byte is allowed to leave.
Why static generation waitsA file on disk or in a CDN has no notion of partial content. Prerendering must have the whole document, so it awaits allReady and discards the streaming property it just paid for.
Streaming (dynamic)Buffered (prerender)
Consumptionread chunks as they arriveawait allReady, then read
First byte+11ms+53ms
Fallbacks in outputyes, then patched by $RCno — boundaries already resolved
Errors mid-renderalready-sent bytes cannot be recalledcan fail the whole build cleanly
Where it runsper requestbuild 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 / EdgeNode
Entry pointreact-dom/server.edgereact-dom/server.node
FunctionrenderToReadableStreamrenderToPipeableStream
Returnsa ReadableStreaman object with pipe and abort
Directionconsumer pullsyou push into a Writable
Shell signalthe returned promise resolvesonShellReady callback
CancellationAbortSignal via signalabort() on the returned object
BackpressuredesiredSize / pullwrite() returning false, then drain
probe/rest.mjs — Node side, writes observed on a Writable
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
the Web / Edge call shape
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' },});
await renderToReadableStreamThe promise resolves when the shell is ready, not when the render finishes. Rejecting means the shell itself failed, which is the one case you can still turn into a 500.
signalAborting tears down in-flight work and errors every unresolved boundary.
new Response(stream)The stream is the body. Nothing copies it; the platform drains it, and that draining is where backpressure enters.
the Node call shape
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); },});
onShellReadyThe callback equivalent of the resolved promise. Headers must be set before pipe, because writing the first chunk commits them.
onShellErrorSeparate from onError precisely because this is the only failure that happens before any byte is on the wire.
pipe(res)Push, not pull. Backpressure arrives as 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.

probe/rest.mjs — propagation depth
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)
als.run(store, fn)Every async resource created inside fn inherits the store — including the continuations scheduled by each await.
getStore()Reads the current context. Returns undefined outside any run, which is the error Next.js reports when a request API is called from module scope.
Why it matters hereRendering is interleaved — many components are suspended mid-flight at once. A module-level variable would be shared across concurrent requests; the async context is per-continuation.

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.

One response, two consumers

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.

what the browser receives, in order
<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>
__next_f = […]A plain array, defined in the first script the parser meets. Rows can start accumulating before any JavaScript bundle has been fetched.
.push replacedOnce the runtime loads it swaps 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, …]The leading 1 is a tag for the row-data channel; other tags carry bootstrap instructions.
Row ids againRows arrive interleaved with HTML boundary patches, and out of numeric order. Resolution is by id, so arrival order is irrelevant here too.

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.

Buffer promoted to stream, rows resolved by id
Every stage opens its own breakdown.

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.

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:

PartSubjectPicks up
1App Router and Server Components (this page)—
2Request and response machineryHow the Web Request maps onto Node's IncomingMessage
3Route Handlers and streaming responsesReadableStream.from, async generators as response bodies, SSE
4Data fetching and the cacheThe patched fetch, deduplication, revalidation
5The build pipelineWhere the client manifest that Flight needs comes from
6Client router internalsRSC-only navigations, prefetch, Next-Router-State-Tree
7Edge and Node runtimesWhat actually differs beneath the two entry points
Back to JIT-Atlas