Server Components inside: the Flight protocol
Read the rows React 19.3.1 sends for Server Components: ids, tags, $ references, client references, lazy rows, and how the Flight client rebuilds elements.
- 01Read a payload row by rowSplit an RSC payload into rows, read each row's hexadecimal id and tag, and say what every $ string in it stands for.
- 02Follow a client referenceTrace a "use client" file from registerClientReference on the server, through an import row, to preloadModule and requireModule in the browser.
- 03Explain streaming and errorsSay why some values get rows of their own that arrive later, how the client waits for them with chunks, and what an error row carries.
What a Server Component sends the browser
React Server Components showed that a Server Component runs on the server and only its output reaches the browser, as rows of text called the RSC payload. That lesson read the rows with a small model. This one opens React 19.3.1 itself. React calls the format Flight: the server half lives in ReactFlightServer.js, the browser half in ReactFlightClient.js. Here is the Server Component we start with:
import AddToCart from "./AddToCart.jsx";
export default function ProductPage() {
return (
<main>
<h1>Green tea</h1>
<p>Loose leaf, 100 g</p>
<AddToCart productId="green-tea" price={4} />
</main>
);
}It ran on a real server, with react-server-dom-webpack 19.3.1, the package React publishes for apps built with webpack. A Server Component can't run in a lesson page, so the block above is for reading. These are the bytes the server wrote, from its production build:
1:I["./AddToCart.jsx",["add-to-cart","add-to-cart.js"],"default"]
0:["$","main",null,{"children":[["$","h1",null,{"children":"Green tea"}],["$","p",null,{"children":"Loose leaf, 100 g"}],["$","$L1",null,{"productId":"green-tea","price":4}]]}]Two lines, two rows. Each starts with a number and a colon. Row 1 names the file ./AddToCart.jsx. Row 0 is the page itself, as JSON: ["$","main",null,{…}] is a <main> element, and "$L1" stands for whatever row 1 holds. The rest of this lesson reads those strings, letter by letter, in React's source.
The browser half is real code you can run. This example feeds the same two rows to React's own Flight client and renders the result:
import "./bundler.js";import { Suspense, use } from "react";import { createFromReadableStream } from "react-server-dom-webpack@19.3.1/client.browser?deps=react@19.3.1,react-dom@19.3.1";import { rows } from "./payload.js"; // The rows arrive as bytes, the way a fetch response's body brings them.const body = new ReadableStream({ start(controller) { for (const row of rows) controller.enqueue(new TextEncoder().encode(row + "\n")); controller.close(); },});const page = createFromReadableStream(body); function ServerOutput() { return use(page);} export default function App() { return ( <Suspense fallback={<p>Waiting for row 0...</p>}> <ServerOutput /> </Suspense> );}- Line 1 runs
bundler.jsfirst. In a real app, webpack's own code in the page gives React two functions,__webpack_chunk_load__and__webpack_require__. Thebundler.jstab writes small versions of both that log every call. - Line 3 imports
createFromReadableStreamfrom react-server-dom-webpack 19.3.1, its production build for browsers, because the rows came from a production server. - Lines 7 to 12 put the two rows from the
payload.jstab into a stream, the way afetchresponse's body brings them. - Line 13 hands the stream to the client. It returns a thenable for row 0 right away, and line 16 reads it with use, which suspends until row 0 has arrived.
The result is the page, with a working Add to cart button: click it and the count goes up, because AddToCart is a Client Component running in the browser. The console under the result shows two calls: __webpack_chunk_load__("add-to-cart") while row 1 was read, and __webpack_require__("./AddToCart.jsx") when React rendered the button. Press Edit & run and, in the payload.js tab, change Green tea to Oolong: the heading changes, though ProductPage never ran in your browser. You edited its output.
ProductPage and writes what it returned as rows of text. The browser's Flight client reads the rows back into React elements, and react-dom renders them like any others.webpack isn't the only choice. React publishes the same two halves for other bundlers: react-server-dom-turbopack, and react-server-dom-parcel, which the Server Functions lesson used. Each is ReactFlightServer.js and ReactFlightClient.js plus a few small files that say how that bundler names and loads code, the same plan as the host configs in the tour of the repository. This is the whole browser build of react-server-dom-webpack, from ReactFlightClientConfig.dom-browser.js, lines 10 to 20:
export {default as rendererVersion} from 'shared/ReactVersion';
export const rendererPackageName = 'react-server-dom-webpack';
export * from 'react-client/src/ReactFlightClientStreamConfigWeb';
export * from 'react-client/src/ReactClientConsoleConfigBrowser';
export * from 'react-client/src/ReactClientDebugConfigBrowser';
export * from 'react-server-dom-webpack/src/client/ReactFlightClientConfigBundlerWebpack';
export * from 'react-server-dom-webpack/src/client/ReactFlightClientConfigBundlerWebpackBrowser';
export * from 'react-server-dom-webpack/src/client/ReactFlightClientConfigTargetWebpackBrowser';
export * from 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM';
export const usedWithSSR = false;Line 2 names the package. Lines 7 to 9 bring in the webpack files; you'll read the first two in the client references section. The rest, ReactFlightClient.js itself, is the same for every bundler.
A furniture shop doesn't send you the factory. It sends the finished parts in numbered boxes, plus an assembly sheet in box 0 that refers to the other boxes by number. Some parts come from another workshop: their box holds a slip saying who makes them, and you fetch them yourself. A box that isn't ready yet comes in a later delivery, and you build around the gap.
- In real life: The order, packed into numbered boxes
- In React: The payload, written as numbered rows
- In real life: A box holding only a slip: "part made by the cabinet maker, workshop 2"
- In React: An import row naming a Client Component's module and chunk
- In real life: The assembly sheet in box 0, which says "the shelf from box 3"
- In React: Row 0's JSON, with "$L3" where row 3's value goes
- In real life: Box 3 arriving tomorrow, while you build the rest
- In React: A row that comes later, while Suspense shows a fallback
The Flight protocol sends a tree of Server Component output as numbered rows of text. Each row is an id in hexadecimal, an optional tag letter, and data, usually JSON. Strings that start with $ refer to other rows or stand for values JSON can't hold. Client Components travel as import rows: a module id and chunks, never their code. The browser's Flight client keeps a chunk for every row id and rebuilds React elements as rows arrive.
Every row is an id, a tag, and data
The server builds the start of every row with serializeRowHeader, at ReactFlightServer.js, lines 3150 to 3152:
function serializeRowHeader(tag: string, id: number) {
return id.toString(16) + ':' + tag;
}Line 2 writes the id with id.toString(16): in hexadecimal, base 16, where the digits run 0 to 9 and then a to f. Row 10 is a: and row 11 is b:. A colon follows, then the tag, one letter. The rows themselves are built in emitImportChunk and emitModelChunk, lines 4776 to 4812 (the cut lines write hint rows and symbol rows):
function emitImportChunk(
request: Request,
id: number,
json: string,
debug: boolean,
): void {
const row = serializeRowHeader('I', id) + json + '\n';
const processedChunk = stringToChunk(row);
if (__DEV__ && debug) {
request.completedDebugChunks.push(processedChunk);
} else {
request.completedImportChunks.push(processedChunk);
}
}
// …
function emitModelChunk(request: Request, id: number, json: string): void {
const row = id.toString(16) + ':' + json + '\n';
const processedChunk = stringToChunk(row);
request.completedRegularChunks.push(processedChunk);
}- Line 7: an import row is the header with tag
I, the JSON, and a newline. Line 12 puts it on the request's list of finished import rows,completedImportChunks. - Line 19: a model row, React's name for a row that holds a value, has no tag at all: the id, a colon, the JSON, a newline. Line 21 puts it on
completedRegularChunks, a different list. You'll see why the lists matter.
JSON is fine for small values. A long string would need escaping and could hold newlines, so strings of 1,024 characters or more get a text row instead, with their length up front. From emitTextChunk, lines 5185 to 5188:
const textChunk = stringToChunk(text);
const binaryLength = byteLengthOfChunk(textChunk);
const row = id.toString(16) + ':T' + binaryLength.toString(16) + ',';
const headerChunk = stringToChunk(row);Line 2 measures the text in bytes, and line 3 writes T, that length in hexadecimal, and a comma. No newline follows the text: the length says where it ends. A Server Component that passed a 1,280-character story to a Client Component got the row 2:T500,Picked by hand. Picked by hand. …, because 500 in hexadecimal is 1,280. You can read it in the decoder below.
T, gives its length in bytes instead, so its text can hold anything, newlines too.The browser reads the bytes one at a time with a small state machine in processBinaryChunk, at ReactFlightClient.js, lines 5337 to 5411. The cut line lists the other tags that carry a length, the ones for binary data like Uint8Array:
while (i < chunkLength) {
let lastIdx = -1;
switch (rowState) {
case ROW_ID: {
const byte = chunk[i++];
if (byte === 58 /* ":" */) {
// Finished the rowID, next we'll parse the tag.
rowState = ROW_TAG;
} else {
rowID = (rowID << 4) | (byte > 96 ? byte - 87 : byte - 48);
}
continue;
}
case ROW_TAG: {
const resolvedRowTag = chunk[i];
if (
resolvedRowTag === 84 /* "T" */ ||
// …
resolvedRowTag === 86 /* "V" */
) {
rowTag = resolvedRowTag;
rowState = ROW_LENGTH;
i++;
} else if (
(resolvedRowTag > 64 && resolvedRowTag < 91) /* "A"-"Z" */ ||
resolvedRowTag === 35 /* "#" */ ||
resolvedRowTag === 114 /* "r" */ ||
resolvedRowTag === 120 /* "x" */
) {
rowTag = resolvedRowTag;
rowState = ROW_CHUNK_BY_NEWLINE;
i++;
} else {
rowTag = 0;
rowState = ROW_CHUNK_BY_NEWLINE;
// This was an unknown tag so it was probably part of the data.
}
continue;
}
case ROW_LENGTH: {
const byte = chunk[i++];
if (byte === 44 /* "," */) {
// Finished the rowLength, next we'll buffer up to that length.
rowState = ROW_CHUNK_BY_LENGTH;
} else {
rowLength = (rowLength << 4) | (byte > 96 ? byte - 87 : byte - 48);
}
continue;
}
case ROW_CHUNK_BY_NEWLINE: {
// We're looking for a newline
lastIdx = chunk.indexOf(10 /* "\n" */, i);
break;
}
case ROW_CHUNK_BY_LENGTH: {
// We're looking for the remaining byte length
lastIdx = i + rowLength;
if (lastIdx > chunk.length) {
lastIdx = -1;
}
break;
}
}- Lines 4 to 13,
ROW_ID: every byte before the colon is a hexadecimal digit. Line 10 turns the character into its value (byte - 48for 0 to 9,byte - 87for a to f) and shifts the id left by 4 bits, one digit. - Lines 14 to 39,
ROW_TAG: lines 16 to 23 spot a tag with a length, likeT. Lines 24 to 32 spot any other capital letter (and#,r,x). Lines 33 to 37: anything else, like[or", isn't a tag but the start of JSON, so the tag is 0. - Lines 40 to 49 read the hexadecimal length, up to the comma.
- Lines 50 to 62: the row ends at the next newline, or after exactly that many bytes. If the row isn't all here yet,
lastIdxis-1and the client keeps the bytes until the next chunk of the stream arrives.
A whole row then goes to processFullStringRow, which picks what to do by tag. Shortened from lines 5223 to 5316:
switch (tag) {
case 73 /* "I" */: {
resolveModule(response, id, row, streamState);
return;
}
// …
case 69 /* "E" */: {
resolveErrorModel(response, id, row, streamState);
return;
}
// …
default: /* """ "{" "[" "t" "f" "n" "0" - "9" */ {
// …
// We assume anything else is JSON.
resolveModel(response, id, row, streamState);
return;
}
}Lines 2 to 5 send an import row to resolveModule, lines 7 to 10 an error row to resolveErrorModel, and lines 12 to 16 everything without a tag to resolveModel, as JSON. The comment on line 12 lists the characters JSON can start with. How to read the table: each row is a tag, what a row with it holds, and which builds send it.
| Tag | What the row holds | Who sends it |
|---|---|---|
| No tag: the row is JSON | A value: an element tree, an array, a string, or a single reference. | Every build. |
I | An import: a Client Component's module id, its chunks, and the export to use. | Every build, before the rows that use it. |
E | An error. Production sends only a digest; development adds the message and stack. | Every build, after the other rows. |
T | Text with its byte length in hexadecimal before a comma, used for strings of 1,024 characters or more. | Every build. |
H | A hint, like a stylesheet to preload. It has no id. | Every build. |
D, J, W, and :N | Debugging information: which component made a row, what it waited for, console calls, and the time origin. | Development builds only. A production client rejects them. |
Decode a payload row by row
Here are eight payloads React 19.3.1 wrote for this lesson's pages. Pick one and press Step: the left pane marks the row that just arrived, and the right pane shows what the client reads in it, including every $ reference and whether the row it points at has arrived yet. Start with ProductPage, then Reviews later. The next sections explain each kind of row; come back and try the rest as you go.
2 rows1:I["./AddToCart.jsx",["add-to-cart","add-to-cart.js"],"default"]0:["$","main",null,{"children":[["$","h1",null,{"children":"Green tea"}],["$","p",null,{"children":"Loose leaf, 100 g"}],["$","$L1",null,{"productId":"green-tea… (176 characters)
id 1 = 1- Id
1in hexadecimal is row 1.- Tag
I, so it's a import row.- References
- None.
Names a Client Component: its module id, the chunks to load, and the export to use. Module "./AddToCart.jsx", chunks ["add-to-cart","add-to-cart.js"], export "default".
You can also edit the rows in the box, or paste a payload from your own app; the last section shows where to find one. The playground never runs anything you paste: it only reads the text.
Values that JSON can't hold get a $ tag
JSON has strings, numbers, booleans, null, arrays, and plain objects. Props can be much more. This Server Component passes nine of them to a Client Component, TeaCard:
import TeaCard from "./TeaCard.jsx";
export default function TeaPage() {
return (
<TeaCard
name="Green tea"
label="$4 a tin"
restockedOn={new Date("2026-11-02T12:00:00.000Z")}
stock={new Map([["green-tea", 2]])}
sizes={new Set(["50 g", "100 g"])}
sold={12000000000000000000n}
discount={undefined}
rating={-0}
favorite={Symbol.for("favorite")}
/>
);
}The server walks the value with renderModelDestructive, which decides what each part becomes. A shortened view of ReactFlightServer.js, lines 3797 to 4357, keeping one branch for each kind of value:
// Special Symbol, that's very common.
if (value === REACT_ELEMENT_TYPE) {
return '$';
}
// …
if (typeof value === 'object') {
switch ((value as any).$$typeof) {
case REACT_ELEMENT_TYPE: {
// …
const newChild = renderElement(
request,
task,
element.type,
// $FlowFixMe[incompatible-call] the key of an element is null | string | ReactOptimisticKey
element.key,
ref,
props,
__DEV__ ? element._store.validated : 0,
);
// …
return newChild;
}
// …
}
if (isClientReference(value)) {
return serializeClientReference(
request,
parent,
parentPropertyName,
value as any,
);
}
// …
if (typeof value.then === 'function') {
// …
const promiseId = serializeThenable(request, task, value as any);
const promiseReference =
enableFlightWeakThenables && (value as any).status === 'pending_weak'
? serializeWeakPromiseID(promiseId)
: serializePromiseID(promiseId);
writtenObjects.set(value, promiseReference);
return promiseReference;
}
// …
if (value instanceof Map) {
return serializeMap(request, value);
}
if (value instanceof Set) {
return serializeSet(request, value);
}
// …
if (value instanceof Date) {
return serializeDate(value);
}
// …
}
if (typeof value === 'string') {
// …
return escapeStringValue(value);
}
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'number') {
return serializeNumber(value);
}
if (typeof value === 'undefined') {
return serializeUndefined();
}
if (typeof value === 'function') {
if (isClientReference(value)) {
return serializeClientReference(
request,
parent,
parentPropertyName,
value as any,
);
}
// …
} else if (/^on[A-Z]/.test(parentPropertyName)) {
throw new Error(
'Event handlers cannot be passed to Client Component props.' +
describeObjectForErrorMessage(parent, parentPropertyName) +
'\nIf you need interactivity, consider converting part of this to a Client Component.',
);
// …
throw new Error(
'Functions cannot be passed directly to Client Components ' +
'unless you explicitly expose it by marking it with "use server". ' +
'Or maybe you meant to call this function rather than return it.' +
describeObjectForErrorMessage(parent, parentPropertyName),
);
}
}- Lines 2 to 4: the special symbol React elements carry (see React elements) becomes the one-character string
"$". - Lines 8 to 21: an element goes to
renderElement. The next section follows it. - Lines 26 to 33: a Client Component goes to
serializeClientReference, two sections down. - Lines 35 to 44: anything with a
thenmethod is a promise:serializeThenablegives it a row and line 41 writes$@and that row's id. (Line 39's weak promises exist only in React's experimental builds.) - Lines 46 to 55: a
Map, aSet, and aDateget helpers of their own. Many more types are cut here, likeFormData, typed arrays, and streams. - Lines 59 to 74: strings are escaped, booleans stay, numbers and
undefinedget helpers. - Lines 76 to 99: a function can only be a Client Component or a Server Function (cut). Anything else throws, with the two messages the Server Components lesson showed.
The helpers are small. From lines 3130 to 3148 and lines 3613 to 3621:
function serializeUndefined(): string {
return '$undefined';
}
function serializeDate(date: Date): string {
// …
return '$D' + date.toJSON();
}
// …
function serializeBigInt(n: bigint): string {
return '$n' + n.toString(10);
}function escapeStringValue(value: string): string {
if (value[0] === '$') {
// We need to escape $ prefixed strings since we use those to encode
// references to IDs and as special symbol values.
return '$' + value;
} else {
return value;
}
}undefined becomes the string "$undefined", a Date becomes $D and its ISO text, a bigint becomes $n and its digits. And since every string starting with $ means something, a real string like "$4 a tin" gets a second $ on line 5 of escapeStringValue: "$$4 a tin". The browser reverses all of it in parseModelString. Shortened from ReactFlightClient.js, lines 2662 to 2959:
if (value[0] === '$') {
if (value === '$') {
// …
return REACT_ELEMENT_TYPE;
}
switch (value[1]) {
case '$': {
// This was an escaped string value.
return value.slice(1);
}
case 'L': {
// Lazy node
const id = parseInt(value.slice(2), 16);
const chunk = getChunk(response, id);
// …
// We create a React.lazy wrapper around any lazy values.
// When passed into React, we'll know how to suspend on this.
return createLazyChunkWrapper(chunk, 0);
}
case '@': {
// Promise
const id = parseInt(value.slice(2), 16);
const chunk = getChunk(response, id);
// …
return chunk;
}
// …
case 'S': {
// Symbol
return Symbol.for(value.slice(2));
}
// …
case 'Q': {
// Map
const ref = value.slice(2);
return getOutlinedModel(response, ref, parentObject, key, createMap);
}
case 'W': {
// Set
const ref = value.slice(2);
return getOutlinedModel(response, ref, parentObject, key, createSet);
}
// …
case 'u': {
// matches "$undefined"
// Special encoding for `undefined` which can't be serialized as JSON otherwise.
return undefined;
}
case 'D': {
// Date
return new Date(Date.parse(value.slice(2)));
}
case 'n': {
// BigInt
return BigInt(value.slice(2));
}
// …
default: {
// We assume that anything else is a reference ID.
const ref = value.slice(1);
return getOutlinedModel(response, ref, parentObject, key, createModel);
}
}
}
return value;- Line 1: only strings starting with
$are special. Line 65 returns every other string as it is. - Lines 2 to 5: a lone
"$"is the element marker again. - Line 6 picks by the second character. Lines 7 to 10:
$$was an escaped string, so drop one$. - Lines 11 to 26:
$Land$@read the row id in hexadecimal (parseInt(…, 16)) and get that row's chunk.$Lwraps it for React;$@returns it as a promise. The chunks section explains both. - Lines 28 to 56: a symbol comes back with
Symbol.for, a Map or Set is built from the entries in another row, and$undefined,$Dand$nundo the helpers above. - Lines 58 to 62: anything else, like
$4, is a reference to the value in row 4.
One more step matters for undefined. The client walks each parsed JSON object with reviveModel, at lines 5673 to 5686:
for (const k in value) {
if (hasOwnProperty.call(value, k)) {
if (k === __PROTO__) {
delete (value as any)[k];
} else {
const walked = reviveModel(response, (value as any)[k], value, k);
if (walked !== undefined) {
(value as any)[k] = walked;
} else {
delete (value as any)[k];
}
}
}
}Line 6 revives each property. Lines 7 to 11: if the result is undefined, the property is deleted rather than set. Now watch all nine props arrive. TeaCard prints what each one is in the browser:
import "./bundler.js";import { Suspense, use } from "react";import { createFromReadableStream } from "react-server-dom-webpack@19.3.1/client.browser?deps=react@19.3.1,react-dom@19.3.1";import { rows } from "./payload.js"; // The rows arrive as bytes, the way a fetch response's body brings them.const body = new ReadableStream({ start(controller) { for (const row of rows) controller.enqueue(new TextEncoder().encode(row + "\n")); controller.close(); },});const page = createFromReadableStream(body); function ServerOutput() { return use(page);} export default function App() { return ( <Suspense fallback={<p>Waiting for row 0...</p>}> <ServerOutput /> </Suspense> );}This App.jsx is the one from the top of the lesson; the payload.js tab holds the five rows the server wrote for TeaPage, and the TeaCard.jsx tab prints each prop with its kind. Read the rows and the result together:
labelis"$$4 a tin"in the row and"$4 a tin"on the page.restockedOn,soldandratingare aDate, a bigint, and-0again. Plain JSON would have turned-0into0.stockis"$Q2"andsizesis"$W3": their entries went into rows 2 and 3.favoriteis"$4", and row 4 is"$Sfavorite".discountisn't listed at all, and the last line saysHas a discount prop: false.
Press Edit & run and change a value in payload.js: replace "$n12000000000000000000" with "$D2027-01-01T00:00:00.000Z" and sold becomes a Date. The client trusts the tags, not the prop names. How to read the table: each row is a $ string you can meet, what the server had, and what the client makes.
| In the row | What the server had | What the client makes |
|---|---|---|
"$" | The first item of an element array: ["$", type, key, props]. | A React element (parseModelTuple). |
"$$4 a tin" | A string that starts with $, escaped by escapeStringValue. | The string without the first $. |
"$L1" | A Client Component type, or a value that wasn't ready (serializeLazyID). | A lazy wrapper: React suspends until row 1 is ready. |
"$@3" | A promise (serializePromiseID). | Row 3's chunk itself, a thenable you can use. |
"$4" | A value written in a row of its own (serializeByValueID). | Row 4's value. |
"$undefined" | undefined (serializeUndefined). | undefined, so reviveModel leaves the property out. |
"$D2026-11-02T12:00:00.000Z" | A Date (serializeDate). | A new Date. |
"$n12000000000000000000" | A bigint (serializeBigInt). | A bigint. |
"$Q2", "$W3" | A Map or a Set, its entries in row 2 or 3. | A new Map or Set. |
"$Sfavorite" | A symbol made with Symbol.for("favorite"). | Symbol.for("favorite"), the same symbol. |
"$-0", "$NaN", "$Infinity" | Numbers JSON can't write (serializeNumber). | The same number. |
One more kind of reference is a path. When the same object appears twice, React writes it once and refers to it the second time by where it is (lines 4067 to 4094). Two TeaCards sharing one badge element gave this row 0, where the second badge is "$0:props:children:0:props:badge": row 0, then property by property:
0:["$","main",null,{"children":[["$","$L1",null,{"name":"Green tea","badge":["$","strong",null,{"children":"On sale"}]}],["$","$L1",null,{"name":"Oolong","badge":"$0:props:children:0:props:badge"}]]}]Elements, and rows that come later
Now a page that has to wait. Reviews is an async Server Component that reads a database, and the page passes it to ShowMore, a Client Component, as children:
import AddToCart from "./AddToCart.jsx";
import ShowMore from "./ShowMore.jsx";
async function Reviews() {
const reviews = await db.reviews.all();
return (
<ul>
{reviews.map((review) => (
<li key={review}>{review}</li>
))}
</ul>
);
}
export default function ReviewsPage() {
return (
<main>
<h1>Green tea</h1>
<AddToCart productId="green-tea" price={4} />
<ShowMore label="Reviews">
<Reviews />
</ShowMore>
</main>
);
}The server starts with one task, React's word for "a value to turn into a row": the page element. Tasks get ids from request.nextChunkId++, which starts at 0 (line 732), so the page is always row 0 (lines 814 to 826). When the walk meets an element, renderElement decides what kind it is, at lines 2362 to 2368:
if (
typeof type === 'function' &&
!isClientReference(type) &&
!isOpaqueTemporaryReference(type)
) {
// This is a Server Component.
return renderFunctionComponent(request, task, key, type, props, validated);A function that isn't a client reference is a Server Component, and line 7 calls it, right there, through renderFunctionComponent. Its output is walked in its place, so ReviewsPage itself never shows up in the payload. Anything else is written as an element array, in renderClientElement (lines 2230 to 2245):
const element = __DEV__
? [REACT_ELEMENT_TYPE, type, key, props, debugOwner, debugStack, validated]
: [REACT_ELEMENT_TYPE, type, key, props];
// …
return element;Line 3 is what you've been reading: ["$", type, key, props]. Line 2, in development, adds three more slots for the owner, the stack and key checking. Then Reviews: an async function returns a promise. React wraps it in a lazy value (line 1700), and reading a lazy value whose promise hasn't settled throws it (readThenable). renderModel catches the throw. Shortened from lines 3711 to 3770:
if (typeof x === 'object' && x !== null) {
// $FlowFixMe[method-unbinding]
if (typeof x.then === 'function') {
// Something suspended, we'll need to create a new task and resolve it later.
const newTask = createTask(
// …
const ping = newTask.ping;
(x as any).then(ping, ping);
// …
if (wasReactNode) {
return serializeLazyID(newTask.id);
}
return serializeByValueID(newTask.id);
// …
// Something errored. We'll still send everything we have up until this point.
request.pendingChunks++;
const errorId = request.nextChunkId++;
const digest = logRecoverableError(request, x, task);
emitErrorChunk(
request,
errorId,
// …
if (wasReactNode) {
// …
return serializeLazyID(errorId);
}
// …
return serializeByValueID(errorId);- Lines 3 to 5: a thrown promise means "not ready". React makes a new task for this value, with the next id.
- Lines 7 and 8: when the promise settles,
pingqueues the task to run again. - Lines 10 to 13: in its place, the current row gets
$Lplus the new id for an element, or$plus the id for any other value. Row 0 is finished without waiting. - Lines 15 to 28: any other throw is an error. The next-but-one section reads those.
The ids are written by three tiny functions, at lines 3070 to 3080:
function serializeByValueID(id: number): string {
return '$' + id.toString(16);
}
function serializeLazyID(id: number): string {
return '$L' + id.toString(16);
}
function serializePromiseID(id: number): string {
return '$@' + id.toString(16);
}Same id, three meanings: $4 is "the value in row 4", $L4 is "an element React should wait for", and $@4 is "a promise for row 4". If the value at the very top of a task suspends, like an async page, the task waits instead and tries again on the ping (retryTask, lines 6382 to 6393). Last, the order rows go out in. Shortened from flushCompletedChunks, lines 6548 to 6658:
// We emit module chunks first in the stream so that
// they can be preloaded as early as possible.
const importsChunks = request.completedImportChunks;
// …
// Next comes hints.
const hintChunks = request.completedHintChunks;
// …
// Debug meta data comes before the model data because it will often end up blocking the model from
// completing since the JSX will reference the debug data.
// …
// Next comes model data.
const regularChunks = request.completedRegularChunks;
// …
// Finally, errors are sent. The idea is that it's ok to delay
// any error messages and prioritize display of other parts of
// the page.
const errorChunks = request.completedErrorChunks;Import rows first (lines 1 to 3), so the browser can start downloading JavaScript at once. Then hints, debugging information in development, model rows (lines 11 and 12), and errors last (lines 14 to 17). That's why row 1 came before row 0. Now play the network yourself. Each click on Send the next row sends one row to React's Flight client:
import "./bundler.js";import { Suspense, use, useState } from "react";import { createFromReadableStream } from "react-server-dom-webpack@19.3.1/client.browser?deps=react@19.3.1,react-dom@19.3.1";import { rows } from "./payload.js"; // A stream you fill by hand: each click sends the server's next row.let network;const body = new ReadableStream({ start: (controller) => (network = controller) });const page = createFromReadableStream(body); function ServerOutput() { return use(page);} export default function App() { const [sent, setSent] = useState(0); function sendNextRow() { network.enqueue(new TextEncoder().encode(rows[sent] + "\n")); setSent(sent + 1); } return ( <> <button onClick={sendNextRow} disabled={sent === rows.length}> Send the next row </button> <ol> {rows.slice(0, sent).map((row) => ( <li key={row}><code>{row}</code></li> ))} </ol> <Suspense fallback={<p>Waiting for row 0...</p>}> <ServerOutput /> </Suspense> </> );}Lines 7 to 9 make a stream with nothing in it yet and give it to the client. Line 18 sends rows[sent], the next row from the payload.js tab, and lines 26 to 30 list the rows sent so far. Click four times, slowly:
- Rows 1 and 2: the page still says
Waiting for row 0..., and the console shows a__webpack_chunk_load__for each. The client loads the code before it knows where it goes. - Row 0: the page appears, and the console shows both
__webpack_require__calls. Click Show Reviews:ShowMorerenders its children, which are"$L3", and row 3 hasn't arrived, so its<Suspense>showsLoading Reviews.... - Row 3: the reviews replace the fallback. Nothing else on the page renders again.
Client references: a module id instead of code
How does the server know AddToCart is a Client Component? It never sees its code. This is the file you write:
"use client";
import { useState } from "react";
export default function AddToCart({ productId, price }) {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
Add to cart, ${price} ({count})
</button>
);
}It runs in every live example of this lesson. On the server, a loader replaces it before anything imports it. This is what the real Node.js loader of react-server-dom-webpack 19.3.1 turned it into, recorded for this lesson:
import {registerClientReference} from "react-server-dom-webpack/server";
export default registerClientReference(function() {throw new Error("Attempted to call the default export of file:///app/AddToCart.jsx from the server but it's on the client. It's not possible to invoke a client function from the server, it can only be rendered as a Component or passed to props of a Client Component.");},"file:///app/AddToCart.jsx","default");
The component is gone. The default export is now a function that throws if anyone calls it, wrapped by registerClientReference with the file's URL and the export's name. The loader writes that text in transformClientModule, shortened from ReactFlightWebpackNodeLoader.js, lines 587 to 617:
let newSrc =
'import {registerClientReference} from "react-server-dom-webpack/server";\n';
for (let i = 0; i < names.length; i++) {
const name = names[i];
if (name === 'default') {
newSrc += 'export default ';
newSrc += 'registerClientReference(function() {';
// …
newSrc += '},';
newSrc += JSON.stringify(url) + ',';
newSrc += JSON.stringify(name) + ');\n';Lines 2 and 7 write the import and the call, and lines 10 and 11 add the URL and the name. Whatever the setup, code that runs on the server only ever sees a reference like this one. Here is registerClientReference, shortened from ReactFlightWebpackReferences.js, lines 26 to 59:
const CLIENT_REFERENCE_TAG = Symbol.for('react.client.reference');
// …
export function registerClientReference<T>(
proxyImplementation: any,
id: string,
exportName: string,
): ClientReference<T> {
return registerClientReferenceImpl(
proxyImplementation,
id + '#' + exportName,
false,
);
}
function registerClientReferenceImpl<T>(
proxyImplementation: any,
id: string,
async: boolean,
): ClientReference<T> {
return Object.defineProperties(proxyImplementation, {
$$typeof: {value: CLIENT_REFERENCE_TAG},
$$id: {value: id},
$$async: {value: async},
});
}Line 10 joins the id and the export name with #. Lines 20 to 24 add three properties to the throwing function: $$typeof, set to the symbol on line 1, is how isClientReference recognizes it; $$id is ./AddToCart.jsx#default; $$async is for modules that load asynchronously. When the walk meets this function as an element's type, serializeClientReference runs. Shortened from lines 3172 to 3208:
const clientReferenceKey: ClientReferenceKey =
getClientReferenceKey(clientReference);
const writtenClientReferences = request.writtenClientReferences;
const existingId = writtenClientReferences.get(clientReferenceKey);
if (existingId !== undefined) {
// …
return serializeByValueID(existingId);
}
try {
const clientReferenceMetadata: ClientReferenceMetadata =
resolveClientReferenceMetadata(request.bundlerConfig, clientReference);
// …
const json = stringifyImportMetadata(
request,
clientReferenceMetadata,
false,
);
request.pendingChunks++;
const importId = request.nextChunkId++;
emitImportChunk(request, importId, json, false);
writtenClientReferences.set(clientReferenceKey, importId);
if (parent[0] === REACT_ELEMENT_TYPE && parentPropertyName === '1') {
// …
return serializeLazyID(importId);
}
return serializeByValueID(importId);- Lines 3 to 8: each client reference is written once per payload. The second
AddToCarton a page reuses the same import row. - Lines 10 and 11 look the reference up in the client manifest, the bundler's list of client files.
- Lines 19 and 20 take the next id and write the import row.
- Lines 22 to 25: as an element's type, it becomes
$Land the id, so React can wait while the code loads.
The manifest is a plain object the bundler writes at build time. Take this Server Component, which renders two Client Components that the bundler put in one chunk:
import ShopButton from "./ShopButton.jsx";
import ShopMenu from "./ShopMenu.jsx";
export default function ShopHeader() {
return (
<main>
<ShopMenu />
<ShopButton label="Checkout" />
</main>
);
}The manifest the server used for it:
{
"./ShopMenu.jsx#default": { "id": "./ShopMenu.jsx", "chunks": ["shop", "static/chunks/shop.js"], "name": "default" },
"./ShopButton.jsx#default": { "id": "./ShopButton.jsx", "chunks": ["shop", "static/chunks/shop.js"], "name": "default" }
}resolveClientReferenceMetadata turns a manifest entry into the import row's data, shortened from ReactFlightServerConfigWebpackBundler.js, lines 45 to 87:
export function resolveClientReferenceMetadata<T>(
config: ClientManifest,
clientReference: ClientReference<T>,
): ClientReferenceMetadata {
const modulePath = clientReference.$$id;
let name = '';
let resolvedModuleData = config[modulePath];
if (resolvedModuleData) {
// The potentially aliased name.
name = resolvedModuleData.name;
// …
if (resolvedModuleData.async === true || clientReference.$$async === true) {
return [resolvedModuleData.id, resolvedModuleData.chunks, name, 1];
} else {
return [resolvedModuleData.id, resolvedModuleData.chunks, name];
}
}Line 7 finds the entry by $$id, and line 15 returns three things: the module id, the chunks, and the export name. The chunks come in pairs, a chunk id and its file name. That's exactly ["./ShopMenu.jsx",["shop",…],"default"]. But look at the payload for ShopHeader:
1:"static/chunks/shop.js"
2:I["./ShopMenu.jsx",["shop","$1"],"default"]
3:"./ShopButton.jsx"
4:I["$3",["shop","$1"],"default"]
0:["$","main",null,{"children":[["$","$L2",null,{}],["$","$L4",null,{"label":"Checkout"}]]}]The file name static/chunks/shop.js got a row of its own, row 1, and both import rows say "$1". So did ./ShopButton.jsx, in row 3. React 19.3.1 does this in serializeImportString, shortened from lines 3623 to 3656:
function serializeImportString(request: Request, value: string): string {
// …
if (value.length < MIN_DEDUPLICATED_IMPORT_STRING_LENGTH) {
return escapeStringValue(value);
}
const writtenStrings = request.writtenImportStrings;
const existing = writtenStrings.get(value);
if (existing !== undefined) {
return existing;
}
// …
request.pendingChunks++;
const outlinedId = request.nextChunkId++;
// …
const json: string = stringify(escapeStringValue(value));
// …
request.completedImportChunks.push(
stringToChunk(outlinedId.toString(16) + ':' + json + '\n'),
);
const ref = serializeByValueID(outlinedId);
writtenStrings.set(value, ref);
return ref;
}Line 3: strings shorter than MIN_DEDUPLICATED_IMPORT_STRING_LENGTH, 16 characters (line 2262), are written as they are. ./ShopMenu.jsx has 14, so it stays; ./ShopButton.jsx has 16. Lines 7 to 10 reuse a string already written. Lines 12 to 19 give a new one its own row, on the import list, so it arrives before the import row that needs it. A big app repeats the same chunk files in every import row, so this saves bytes.
AddToCart is only a reference with an id. The manifest turns the id into a module and its chunks, and the import row carries them. The browser starts loading the chunk when the row arrives, and asks for the module only when React renders the component.In the browser, an import row goes to resolveModule, shortened from ReactFlightClient.js, lines 3410 to 3462:
const clientReference = resolveClientReference<$FlowFixMe>(
response._bundlerConfig,
clientReferenceMetadata,
);
// …
// TODO: Add an option to encode modules that are lazy loaded.
// For now we preload all modules as early as possible since it's likely
// that we'll need them.
const promise = preloadModule(clientReference);
if (promise) {
// …
promise.then(
() => resolveModuleChunk(response, blockedChunk, clientReference),
error => triggerErrorOnChunk(response, blockedChunk, error),
);
} else {
// …
}
}Lines 6 to 9, React's own comment: load the modules as early as possible. preloadModule is in the webpack bindings, at ReactFlightClientConfigBundlerWebpack.js, lines 198 to 232:
export function preloadModule<T>(
metadata: ClientReference<T>,
): null | Thenable<any> {
const chunks = metadata[CHUNKS];
const promises = [];
let i = 0;
while (i < chunks.length) {
const chunkId = chunks[i++];
const chunkFilename = chunks[i++];
const entry = chunkCache.get(chunkId);
if (entry === undefined) {
const thenable = loadChunk(chunkId, chunkFilename);
promises.push(thenable);
// $FlowFixMe[method-unbinding]
const resolve = chunkCache.set.bind(chunkCache, chunkId, null);
thenable.then(resolve, ignoreReject);
chunkCache.set(chunkId, thenable);
} else if (entry !== null) {
promises.push(entry);
}
}
if (isAsyncImport(metadata)) {
if (promises.length === 0) {
return requireAsyncModule(metadata[ID]);
} else {
return Promise.all(promises).then(() => {
return requireAsyncModule(metadata[ID]);
});
}
} else if (promises.length > 0) {
return Promise.all(promises);
} else {
return null;
}
}- Lines 7 to 9 read the chunks in pairs: an id, then a file name.
- Lines 10 to 17: a chunk not seen before is loaded with
loadChunkand remembered inchunkCache, so a second component in the same chunk doesn't load it again (lines 18 to 20). - Lines 30 and 31 return a promise for all of them. Until it settles, the row's chunk is
blocked.
loadChunk is in the browser-only file, at ReactFlightClientConfigBundlerWebpackBrowser.js, lines 16 to 34:
const chunkMap: Map<string, string> = new Map();
/**
* We patch the chunk filename function in webpack to insert our own resolution
* of chunks that come from Flight and may not be known to the webpack runtime
*/
const webpackGetChunkFilename = __webpack_require__.u;
__webpack_require__.u = function (chunkId: string) {
const flightChunk = chunkMap.get(chunkId);
if (flightChunk !== undefined) {
return flightChunk;
}
return webpackGetChunkFilename(chunkId);
};
export function loadChunk(chunkId: string, filename: string): Promise<mixed> {
chunkMap.set(chunkId, filename);
return __webpack_chunk_load__(chunkId);
}Line 17 remembers the file name from the row, and line 18 asks webpack to load the chunk. Lines 7 to 14 change __webpack_require__.u, the function webpack uses to turn a chunk id into a file name, so it finds names that came in rows. The module itself is only asked for when React renders the component, in requireModule (lines 236 to 262):
export function requireModule<T>(metadata: ClientReference<T>): T {
let moduleExports = __webpack_require__(metadata[ID]);
// …
if (hasOwnProperty.call(moduleExports, metadata[NAME])) {
return moduleExports[metadata[NAME]];
}
return undefined as any;
}Line 2 gets the module's exports from webpack by the id, and line 5 picks the export by name, default. Watch both with ShopHeader, whose two components share one chunk:
import "./bundler.js";import { Suspense, use } from "react";import { createFromReadableStream } from "react-server-dom-webpack@19.3.1/client.browser?deps=react@19.3.1,react-dom@19.3.1";import { rows } from "./payload.js"; // The rows arrive as bytes, the way a fetch response's body brings them.const body = new ReadableStream({ start(controller) { for (const row of rows) controller.enqueue(new TextEncoder().encode(row + "\n")); controller.close(); },});const page = createFromReadableStream(body); function ServerOutput() { return use(page);} export default function App() { return ( <Suspense fallback={<p>Waiting for row 0...</p>}> <ServerOutput /> </Suspense> );}The console shows __webpack_chunk_load__("shop"), file static/chunks/shop.js once: line 10 of bundler.js asks __webpack_require__.u for the file, and gets the name from row 1. Then two __webpack_require__ calls, one for each component, when React renders them. Both buttons work.
The client keeps a chunk for every row
In the browser, every row id has a chunk: an object that holds the row's state and, later, its value. Not to be confused with webpack's chunks of JavaScript; React uses the word for both. A chunk is a kind of promise, and that's how a promise prop travels. This page passes one without awaiting it:
import ReviewList from "./ReviewList.jsx";
export default function PromisePage() {
// Not awaited: the promise itself goes to the Client Component.
const reviewsPromise = db.reviews.all();
return (
<main>
<h1>Green tea</h1>
<ReviewList reviewsPromise={reviewsPromise} />
</main>
);
}On the server, serializeThenable gives the promise a task of its own, shortened from lines 1109 to 1283:
function serializeThenable(
request: Request,
task: Task,
thenable: Thenable<any>,
): number {
switch (thenable.status) {
case 'fulfilled': {
const newTask = createThenableTask(request, task, thenable);
// …
newTask.model = thenable.value;
pingTask(request, newTask);
return newTask.id;
}
// …
default: {
const newTask = createThenableTask(request, task, thenable);
// …
thenable.then(
value => {
// …
newTask.model = value;
pingTask(request, newTask);
},
// …
return newTask.id;
}
}
}Lines 7 to 13: a promise that has already settled is written right away. Lines 15 to 26: otherwise the new task waits, and line 22 queues it when the value arrives. Line 25 returns the task's id, which the caller writes as $@. In the browser, these are the states a chunk goes through, from ReactFlightClient.js, lines 153 to 168:
const PENDING = 'pending';
// A weak Promise reference. Behaves like PENDING except that when the stream
// closes it transitions to HALTED instead of erroring, because the server
// may intentionally never emit it. Only used when enableFlightWeakThenables
// is on.
const PENDING_WEAK = 'pending_weak';
const BLOCKED = 'blocked';
const RESOLVED_MODEL = 'resolved_model';
const RESOLVED_MODULE = 'resolved_module';
const INITIALIZED = 'fulfilled';
const ERRORED = 'rejected';
// Means it never resolves, even when the connection closes. The shared
// terminal state of a weak chunk that didn't settle before close, of any
// pending chunk at close when partial streams are allowed, and of DEV-only
// debug halts.
const HALTED = 'halted';pending(line 1): something refers to the row, but it hasn't arrived.getChunkmakes one the first time an id is mentioned (lines 1661 to 1681).resolved_model(line 8): the row's text arrived, but hasn't been parsed.resolved_module(line 9): an import row whose JavaScript has loaded.blocked(line 7): waiting on something, like that loading.fulfilledandrejected(lines 10 and 11): the same words as a promise's states. The lines about weak promises andhaltedcover experimental builds and streams that end early.
$L or $@ that points at a row not yet arrived makes a pending chunk; reading it suspends. A JSON row makes it resolved_model: the text is parsed into a value when something reads it, or at once if something is already waiting for it. An import row waits as blocked while its JavaScript loads. An E row makes it rejected.A $@3 becomes chunk 3 itself, which use can read like any promise. A $L3 is wrapped first, in createLazyChunkWrapper, shortened from lines 1635 to 1659:
function createLazyChunkWrapper<T>(
chunk: SomeChunk<T>,
validated: 0 | 1 | 2, // DEV-only
): LazyComponent<T, SomeChunk<T>> {
const lazyType: LazyComponent<T, SomeChunk<T>> = {
$$typeof: REACT_LAZY_TYPE,
_payload: chunk,
_init: readChunk,
};
// …
return lazyType;
}Lines 5 to 9 build the same shape React.lazy returns: $$typeof, a payload, and an _init function. React calls _init when it renders the value, as it does for a component made with lazy; if the value isn't ready, the render suspends the way the concurrent rendering lesson showed. Here it is readChunk, at lines 462 to 486:
function readChunk<T>(chunk: SomeChunk<T>): T {
// If we have resolved content, we try to initialize it first which
// might put us back into one of the other states.
switch (chunk.status) {
case RESOLVED_MODEL:
initializeModelChunk(chunk);
break;
case RESOLVED_MODULE:
initializeModuleChunk(chunk);
break;
}
// The status might have changed after initialization.
switch (chunk.status) {
case INITIALIZED:
return chunk.value;
case PENDING:
case PENDING_WEAK:
case BLOCKED:
case HALTED:
// eslint-disable-next-line no-throw-literal
throw chunk as any as Thenable<T>;
default:
throw chunk.reason;
}
}- Lines 4 to 11: a row that arrived is parsed now, the first time someone reads it. For an import row, that's where
requireModuleruns. - Lines 14 and 15: a finished chunk gives its value.
- Lines 16 to 21: a chunk still waiting is thrown, like a promise, so Suspense shows a fallback until it settles.
- Lines 22 and 23: a failed chunk throws its error.
Send the promise page's rows one at a time. The ReviewList.jsx tab reads the promise with use inside a <Suspense>:
import "./bundler.js";import { Suspense, use, useState } from "react";import { createFromReadableStream } from "react-server-dom-webpack@19.3.1/client.browser?deps=react@19.3.1,react-dom@19.3.1";import { rows } from "./payload.js"; // A stream you fill by hand: each click sends the server's next row.let network;const body = new ReadableStream({ start: (controller) => (network = controller) });const page = createFromReadableStream(body); function ServerOutput() { return use(page);} export default function App() { const [sent, setSent] = useState(0); function sendNextRow() { network.enqueue(new TextEncoder().encode(rows[sent] + "\n")); setSent(sent + 1); } return ( <> <button onClick={sendNextRow} disabled={sent === rows.length}> Send the next row </button> <ol> {rows.slice(0, sent).map((row) => ( <li key={row}><code>{row}</code></li> ))} </ol> <Suspense fallback={<p>Waiting for row 0...</p>}> <ServerOutput /> </Suspense> </> );}After row 0, the heading shows and Loading reviews... waits: reviewsPromise is chunk 3, still pending. Row 3 fulfills it, and the reviews appear. Notice rows 1 and 2 first: the file name ./ReviewList.jsx has 16 characters, so it got row 1 of its own. If the stream closed while a chunk was still pending, every waiting chunk would be rejected with Connection closed. (lines 5724 to 5726).
Errors, digests, and development rows
A Server Component can fail after the page has started streaming. This page wraps a failing one in <Suspense>:
import { Suspense } from "react";
async function BrokenReviews() {
await db.reviews.all();
throw new Error("The reviews service is down");
}
export default function BrokenReviewsPage() {
return (
<main>
<h1>Green tea</h1>
<Suspense fallback={<p>Loading reviews...</p>}>
<BrokenReviews />
</Suspense>
</main>
);
}renderModel catches the error (lines 15 to 28 of its excerpt above) and calls emitErrorChunk. Its end, from lines 4636 to 4645:
} else {
errorInfo = {digest};
}
const row = serializeRowHeader('E', id) + stringify(errorInfo) + '\n';
const processedChunk = stringToChunk(row);
if (__DEV__ && debug) {
request.completedDebugChunks.push(processedChunk);
} else {
request.completedErrorChunks.push(processedChunk);
}Line 2 is the production build: the row holds only {digest}. The digest is whatever your onError callback returned, or "" (lines 4438 to 4444); a framework uses it to find the full error in the server's logs. The development build, cut above, adds the name, message, stack, and the component that failed. Line 9 puts the row on the error list, sent last. In the browser, resolveErrorModel turns it into an error, at lines 3937 to 3944:
const errorInfo = JSON.parse(row);
let error;
if (__DEV__) {
error = resolveErrorDev(response, errorInfo);
} else {
error = resolveErrorProd(response);
}
(error as any).digest = errorInfo.digest;Line 6, in production, makes a new error that says the message was left out on purpose (resolveErrorProd), and line 8 copies the digest onto it. The chunk is rejected, so whatever reads it throws, and the nearest error boundary in the browser catches it. Server Components can't catch errors themselves, so this example puts a Client Component error boundary around the server's output:
import "./bundler.js";import { Suspense, use, useState } from "react";import { createFromReadableStream } from "react-server-dom-webpack@19.3.1/client.browser?deps=react@19.3.1,react-dom@19.3.1";import { rows } from "./payload.js";import ErrorBoundary from "./ErrorBoundary.jsx"; // A stream you fill by hand: each click sends the server's next row.let network;const body = new ReadableStream({ start: (controller) => (network = controller) });const page = createFromReadableStream(body); function ServerOutput() { return use(page);} export default function App() { const [sent, setSent] = useState(0); function sendNextRow() { network.enqueue(new TextEncoder().encode(rows[sent] + "\n")); setSent(sent + 1); } return ( <> <button onClick={sendNextRow} disabled={sent === rows.length}> Send the next row </button> <ol> {rows.slice(0, sent).map((row) => ( <li key={row}><code>{row}</code></li> ))} </ol> <Suspense fallback={<p>Waiting for row 0...</p>}> <ErrorBoundary> <ServerOutput /> </ErrorBoundary> </Suspense> </> );}Lines 33 to 35 wrap the output in ErrorBoundary, from its tab. Send the three rows. After row 0, the heading and Loading reviews... show: the Suspense in row 0 is "$1", and row 1 is "$Sreact.suspense", the symbol React uses for <Suspense>. Its children are "$L2". Row 2 is 2:E{"digest":""}, so reading it throws, and the boundary replaces the page with Something went wrong on the server. Digest: "". The console shows the error React caught. The production client's error messages are shortened to a number: it's React error 441, whose full text is the sentence from resolveErrorProd.
A development server sends much more. This is ProductPage again, from the development build of the same package (the times change on every run):
:N1793036611595.1592
6:I["./AddToCart.jsx",["add-to-cart","add-to-cart.js"],"default"]
1:{"name":"ProductPage","key":null,"env":"Server","stack":[],"props":{}}
2:[]
3:[]
4:[]
5:[]
0:D{"time":16.083833}
0:D"$1"
0:D{"time":16.750541}
0:["$","main",null,{"children":[["$","h1",null,{"children":"Green tea"},"$1","$3",1],["$","p",null,{"children":"Loose leaf, 100 g"},"$1","$4",1],["$","$L6",null,{"productId":"green-tea","price":4},"$1","$5",1]]},"$1","$2",1]
- Line 1,
:Nand a number, has no id: the time the request started, in milliseconds (emitTimeOriginChunk). The timings in later rows count from it. - Line 3, row 1, describes the component:
{"name":"ProductPage","env":"Server",…}. Rows 2 to 5 are the stacks of the four elements, where each was created; empty here, because the recording left out every stack frame. - Lines 8 to 10 are
Drows for row 0: timings, and"$1", which says ProductPage made it. React DevTools and the Performance panel use these. - Line 11: each element has seven slots, the last three pointing at the owner, a stack, and a key check. The import row became row 6, because the debugging rows took ids 1 to 5.
Those rows are why the two builds must match, as processFullStringRow says. Shortened from lines 5257 to 5281:
case 68 /* "D" */: {
if (__DEV__) {
resolveDebugModel(response, id, row);
return;
}
// Fallthrough to share the error with Console entries.
}
// …
case 87 /* "W" */: {
if (__DEV__) {
resolveConsoleEntry(response, row);
return;
}
throw new Error(
'Failed to read a RSC payload created by a development version of React ' +
'on the server while using a production version on the client. Always use ' +
'matching versions on the server and the client.',
);
}In development, line 3 reads the D row. In production, there's nothing to do with it: the case falls through to line 14, which throws, and the whole payload is rejected. A production client fed the payload above stops at its first line.
Reading the payload of a real app
You don't need to build any of this. A framework like Next.js wires the two halves together (see React frameworks). But when a page shows the wrong data, or sends more than you expected, the payload tells you exactly what crossed the network. In a Next.js app built with its App Router (the router that supports Server Components):
- Open the browser's developer tools, then the Network tab, and click a link to another page of your app. A request with the header
RSC: 1and_rscin its address fetches that page's payload. Its response type istext/x-component, and its response is rows like the ones in this lesson. - On the first load, the payload is inside the HTML instead, in small scripts that call
self.__next_f.push, so the browser has it without a second request. - Copy the response into the decoder playground's box. A framework's payload also holds rows of its own, in the same format.
Things to look for:
- Large props in a model row: every value a Server Component passes to a Client Component is in the payload, so pass only what the component uses.
- Many import rows: each is JavaScript the browser downloads. A
"use client"file makes every module it imports client code too. $Lrows that arrive late: those are the parts that waited for data. A<Suspense>around them shows a fallback until they come.
Now sort lines from this lesson's excerpts by the part of React they come from:
return id.toString(16) + ':' + tag;return '$' + value;return createLazyChunkWrapper(chunk, 0);return new Date(Date.parse(value.slice(2)));$$id: {value: id},return [resolvedModuleData.id, resolvedModuleData.chunks, name];const thenable = loadChunk(chunkId, chunkFilename);let moduleExports = __webpack_require__(metadata[ID]);
Sort each line into the file it comes from: the shared server code, the shared browser code, or the webpack bindings.
Common mix-ups
- "The server sends the components' code." No. It sends what Server Components returned, and for a Client Component only a module id and chunk names. The code comes from the bundler's files.
- "The payload is HTML." No. It's rows of JSON and text that the Flight client turns into React elements. HTML for the first load is made from them separately.
- "Rows arrive in id order." No. Import rows go first, then model rows as they finish, then errors. Row 1 often comes before row 0.
- "A string that starts with $ breaks the payload." No.
escapeStringValuewrites it with a second$, and the client removes it. - "The browser downloads a Client Component when React renders it." No. The download starts when its import row arrives, in
preloadModule. Rendering only asks webpack for the loaded module. - "Error messages from the server reach the browser." Only in development. In production an
Erow holds just the digest.
Practice exercises
Predict first, then check in the decoder playground or the live examples.
A payload has twelve rows, 0 to 11. What does row 11 start with, before its tag or data?
function serializeRowHeader(tag: string, id: number) {
return id.toString(16) + ':' + tag;
}b:. Ids are written in hexadecimal: 10 is a and 11 is b. The development payload for the reviews page has rows a to d.
A row is 0:["$","$L1",null,{"note":"$$5 off today"}]. What string does the Client Component get as note?
import "./bundler.js";
import { Suspense, use } from "react";
import { createFromReadableStream } from "react-server-dom-webpack@19.3.1/client.browser?deps=react@19.3.1,react-dom@19.3.1";
import { rows } from "./payload.js";
// The rows arrive as bytes, the way a fetch response's body brings them.
const body = new ReadableStream({
start(controller) {
for (const row of rows) controller.enqueue(new TextEncoder().encode(row + "\n"));
controller.close();
},
});
const page = createFromReadableStream(body);
function ServerOutput() {
return use(page);
}
export default function App() {
return (
<Suspense fallback={<p>Waiting for row 0...</p>}>
<ServerOutput />
</Suspense>
);
}$5 off today. The server escaped the string because it starts with $, so the client removes the first $. TeaCard's label shows the same thing: "$$4 a tin" in the row, "$4 a tin" on the page.
In the payload.js tab, change "$-0" to "$NaN". What does TeaCard show for rating?
import "./bundler.js";import { Suspense, use } from "react";import { createFromReadableStream } from "react-server-dom-webpack@19.3.1/client.browser?deps=react@19.3.1,react-dom@19.3.1";import { rows } from "./payload.js"; // The rows arrive as bytes, the way a fetch response's body brings them.const body = new ReadableStream({ start(controller) { for (const row of rows) controller.enqueue(new TextEncoder().encode(row + "\n")); controller.close(); },});const page = createFromReadableStream(body); function ServerOutput() { return use(page);} export default function App() { return ( <Suspense fallback={<p>Waiting for row 0...</p>}> <ServerOutput /> </Suspense> );}import "./bundler.js";import { Suspense, use } from "react";import { createFromReadableStream } from "react-server-dom-webpack@19.3.1/client.browser?deps=react@19.3.1,react-dom@19.3.1";import { rows } from "./payload.js"; // The rows arrive as bytes, the way a fetch response's body brings them.const body = new ReadableStream({ start(controller) { for (const row of rows) controller.enqueue(new TextEncoder().encode(row + "\n")); controller.close(); },});const page = createFromReadableStream(body); function ServerOutput() { return use(page);} export default function App() { return ( <Suspense fallback={<p>Waiting for row 0...</p>}> <ServerOutput /> </Suspense> );}rating is a number: NaN. JSON can't write NaN, so React writes "$NaN", and the client turns it back into the number. Only the tag decides what the value becomes.
Delete row 3 from payload.js, send the other rows, and click Show Reviews. What does the section show?
import "./bundler.js";import { Suspense, use, useState } from "react";import { createFromReadableStream } from "react-server-dom-webpack@19.3.1/client.browser?deps=react@19.3.1,react-dom@19.3.1";import { rows } from "./payload.js"; // A stream you fill by hand: each click sends the server's next row.let network;const body = new ReadableStream({ start: (controller) => (network = controller) });const page = createFromReadableStream(body); function ServerOutput() { return use(page);} export default function App() { const [sent, setSent] = useState(0); function sendNextRow() { network.enqueue(new TextEncoder().encode(rows[sent] + "\n")); setSent(sent + 1); } return ( <> <button onClick={sendNextRow} disabled={sent === rows.length}> Send the next row </button> <ol> {rows.slice(0, sent).map((row) => ( <li key={row}><code>{row}</code></li> ))} </ol> <Suspense fallback={<p>Waiting for row 0...</p>}> <ServerOutput /> </Suspense> </> );}import "./bundler.js";import { Suspense, use, useState } from "react";import { createFromReadableStream } from "react-server-dom-webpack@19.3.1/client.browser?deps=react@19.3.1,react-dom@19.3.1";import { rows } from "./payload.js"; // A stream you fill by hand: each click sends the server's next row.let network;const body = new ReadableStream({ start: (controller) => (network = controller) });const page = createFromReadableStream(body); function ServerOutput() { return use(page);} export default function App() { const [sent, setSent] = useState(0); function sendNextRow() { network.enqueue(new TextEncoder().encode(rows[sent] + "\n")); setSent(sent + 1); } return ( <> <button onClick={sendNextRow} disabled={sent === rows.length}> Send the next row </button> <ol> {rows.slice(0, sent).map((row) => ( <li key={row}><code>{row}</code></li> ))} </ol> <Suspense fallback={<p>Waiting for row 0...</p>}> <ServerOutput /> </Suspense> </> );}Loading Reviews..., for as long as the stream stays open. The children are a lazy wrapper around chunk 3, which stays pending, so React shows ShowMore's Suspense fallback. The rest of the page works. If the stream closed, the chunk would be rejected with Connection closed.
A Server Component, ShopTop, renders <h1>Tea shop</h1> and <CartBadge count={3} /> in a <header>. The block shows the client manifest. Write the first row the server sends.
{
"./CartBadge.jsx#default": { "id": "./CartBadge.jsx", "chunks": ["cart-badge", "cart-badge.js"], "name": "default" }
}1:I["./CartBadge.jsx",["cart-badge","cart-badge.js"],"default"]
0:["$","header",null,{"children":[["$","h1",null,{"children":"Tea shop"}],["$","$L1",null,{"count":3}]]}]1:I["./CartBadge.jsx",["cart-badge","cart-badge.js"],"default"]. React 19.3.1 wrote exactly this row first, then row 0, the header, with "$L1" as CartBadge's type.
ShopHeader's two Client Components are in the same chunk. How many times does the browser call __webpack_chunk_load__? Predict, then run it.
import "./bundler.js";
import { Suspense, use } from "react";
import { createFromReadableStream } from "react-server-dom-webpack@19.3.1/client.browser?deps=react@19.3.1,react-dom@19.3.1";
import { rows } from "./payload.js";
// The rows arrive as bytes, the way a fetch response's body brings them.
const body = new ReadableStream({
start(controller) {
for (const row of rows) controller.enqueue(new TextEncoder().encode(row + "\n"));
controller.close();
},
});
const page = createFromReadableStream(body);
function ServerOutput() {
return use(page);
}
export default function App() {
return (
<Suspense fallback={<p>Waiting for row 0...</p>}>
<ServerOutput />
</Suspense>
);
}import "./bundler.js";import { Suspense, use } from "react";import { createFromReadableStream } from "react-server-dom-webpack@19.3.1/client.browser?deps=react@19.3.1,react-dom@19.3.1";import { rows } from "./payload.js"; // The rows arrive as bytes, the way a fetch response's body brings them.const body = new ReadableStream({ start(controller) { for (const row of rows) controller.enqueue(new TextEncoder().encode(row + "\n")); controller.close(); },});const page = createFromReadableStream(body); function ServerOutput() { return use(page);} export default function App() { return ( <Suspense fallback={<p>Waiting for row 0...</p>}> <ServerOutput /> </Suspense> );}Once. The first import row loads shop and stores it in chunkCache. The second finds it there and doesn't load it again. __webpack_require__ still runs twice, once for each module.
Check your understanding
For each question, ask which side writes the string and which side reads it.
Question 1 of 7In
0:["$","$L1",null,{"price":4}], what does"$L1"stand for?Choose an answer to see the explanation.
Question 2 of 7Why does the import row
1:I[...]come before row 0 in the payload?Choose an answer to see the explanation.
Question 3 of 7A Server Component renders
<TeaCard discount={undefined} />. What does TeaCard get?Choose an answer to see the explanation.
Question 4 of 7What does this development row's id,
a, mean?Read the code, then predictTexta:J{"name":"","start":1.4301669999999973,"end":52.186249999999994,"env":"Server","owner":"$9"}Choose an answer to see the explanation.
Question 5 of 7In a production build, what does the browser learn when a Server Component throws?
Choose an answer to see the explanation.
Question 6 of 7When does the browser call
__webpack_require__for a Client Component?Choose an answer to see the explanation.
Question 7 of 7An async Server Component deep inside row 0 hasn't finished when the rest of row 0 is ready. What does row 0 hold in its place?
Choose an answer to see the explanation.
Key takeaways
- A Server Component's output travels as rows: an id in hexadecimal, a colon, an optional tag letter, and data, usually JSON ending at a newline. Text rows give a length instead.
- Strings starting with
$are references and special values:$Llazy,$@promise,$1another row,$D,$n,$Q,$W,$undefined, and$$for a real$. - Server Components run on the server and leave only output. Client Components become references:
registerClientReference, the manifest, and anIrow with a module id and chunks. - Anything not ready gets its own row and arrives later. Import rows go first, errors last. The browser keeps a chunk per id, and reading a pending one suspends.
- In production, an error row holds only a digest. Development builds add debugging rows that a production client refuses.
Remember the one-liner.
The server writes what components returned as numbered rows; the browser rebuilds elements from them as they arrive.
Coming next: How the React Compiler works. It follows a component through the compiler, which adds memoization to it before it ever runs.