8.3 React Server Components and the Flight protocol
A Server Component can await a database, read a secret key, or touch the file system. None of that can ever ship to a browser. So how does its rendered UI get to the client at all — and how does React keep the interactive parts working alongside it?
Flight is a wire format for element output
Start with the one idea everything else hangs on. The Flight Protocol is React's wire format for streaming React Server Component output from server to client. It serializes React Elements (not Fibers) into a JSON-like, streamable line-based format.
The Server Component function itself never travels. It runs once on the server, produces an element like <div>John</div>, and then it is erased. Only that element — a plain, serializable description — goes onto the wire.
An element on the wire is just an array
A React element in your code is an object with a type, a key, and props. On the wire, Flight writes that same shape as a small array whose first slot is the marker "$" — meaning "this is a React element."
Element marker anatomy
["$", "div", null, {"children": [...]}]
│ │ │ │
│ │ │ └── props (including children)
│ │ └── React key (null if none)
│ └── type (string for host, or a row reference like "$1" or "$L3")
└── Element marker — "this is a React element"
Maps to a React Element:
{
$$typeof: Symbol(react.element),
type: "div",
key: null,
props: { children: [...] }
}
So ["$","div",null,{"children":"Hello"}] on the wire becomes the element { type:"div", props:{children:"Hello"} } in the browser. Same description, two encodings.
The payload is a stream of ID:VALUE rows
A Flight payload is not one big blob. It is a sequence of lines. Each line follows the pattern ID:VALUE. Rows can arrive out of order — the stream stays open until all pending work resolves.
Line format example
1:"$Sreact.suspense" ← Row ID 1 (a Symbol definition)
0:[...] ← Row ID 0 (the root element)
3:I[...] ← Row ID 3 (a Client Component import)
4:{...} ← Row ID 4 (plain data — Server Action metadata)
2:[...] ← Row ID 2 (streamed later)
The row ID is a handle. One row can point at another by its ID, which is how the format stays flat while still describing a tree. A handful of one- and two-character prefixes tell the client how to read each value.
| Prefix | Meaning | Example |
|---|---|---|
$ | React Element marker (position 0 in the array) | ["$","div",null,{}] |
$S___ | Symbol — creates Symbol.for('___') | "$Sreact.suspense" |
$L# | Lazy reference — row # may not have arrived yet | "$L2" |
$h# | Server Action reference — metadata in row # | "$h4" |
$# | Direct row reference — use value from row # now | "$1" |
I[...] | Client Component import instruction | I["./Like.js",[],"default"] |
{...} | Plain data object (e.g. Server Action metadata) | {"id":"mod#fn","bound":null} |
The server/client boundary: collapse vs hole
Here is the heart of RSC. When the server walks the element tree to build the payload, it treats two kinds of component completely differently.
A Server Component is called. It runs, returns host elements (<div>, <h1>, text), and then the function disappears. We say it collapses to its output. By the time the payload is written, there is no trace that <Page> ever existed — only the <div> it produced.
A Client Component is not called on the server. Its source can use useState and the DOM, which the server must not run. Instead the server leaves a hole: an import instruction (I[...]) that tells the browser which chunk to load, plus a $L# reference where that component belongs in the tree.
SERVER CLIENT
────── ──────
async function ServerComp() { // ServerComp doesn't exist here
const data = await db.query();
return <div>{data.name}</div>; { type: "div", props: { children: "John" } }
} // just the output, not the function
// erased — only its output travels createFiberFromElement → Fiber { tag:5, type:"div" }
So a Flight payload is a mix: solid blocks of plain host elements (collapsed Server Components) with holes punched in it (Client Component references) that the browser fills by loading and running real code.
| Node in payload | Host type? | Collapsed on server? | Client fiber |
|---|---|---|---|
Server Component output — <div>, <h1>, text | ✅ | ✅ function ran, only output remains | HostComponent (5) / HostText (6) / Fragment (7) |
Client Component reference — I[…] + "$L#" | ❌ | ❌ left as an un-rendered hole | FunctionComponent (0) — runs in browser |
Markers — "$Sreact.suspense", lazy "$L#" | ❌ | partial (fallback now, children later) | SuspenseComponent (13) / LazyComponent (16) |
Why fibers can never go on the wire
You might wonder why React serializes elements and not the fiber tree it is about to build anyway. The answer is simple: a fiber is not serializable. The same UI exists in three forms at three stages, and only the element form can be written to text.
FLIGHT (Wire Format) REACT ELEMENT REACT FIBER
──────────────────── ───────────── ───────────
["$","div",null,{}] { {
$$typeof: Symbol, tag: 5,
Serializable JSON type: "div", type: "div",
Streamable key: null, pendingProps: {},
No functions props: {}, memoizedProps: {},
References only _owner: ... memoizedState: ...,
} stateNode: DOM node,
updateQueue: ...,
Plain object child: Fiber,
Immutable sibling: Fiber,
Description only return: Fiber,
flags: ...,
}
Internal to React
Mutable
Has state/effects
A fiber carries a live DOM node, hook state, and pointers back to its parent and siblings. Those references are circular and tied to one running browser — there is nothing to write to a text stream. An element, by contrast, is a flat, immutable description: pure data.
It all funnels into the same reconciler
Once the browser's Flight client turns wire rows back into plain elements, RSC is over. From that point on, nothing is RSC-aware. The elements go through reconcileChildren → createFiberFromElement — the exact path that JSX and SSR hydration use.
This is why react-dom contains zero RSC-specific code. RSC is not a second renderer in the browser; it is a second source of React elements. createFiberFromElement dispatches on element.type alone — a string "div" becomes a host fiber, a resolved function becomes a Client Component fiber. There is no "Server Component" fiber tag, because by the time elements reach the reconciler, Server Components have already collapsed to plain host output.
The real code
A real Flight payload (the recipe pagination example). Read it as five rows — the order they were flushed in, which is not their numeric order:
1:"$Sreact.suspense"
0:["$","div",null,{"children":[["$","h1",null,{"children":"Pagination"}],["$","$1",null,{"fallback":["$","p",null,{"style":{"color":"#888"},"children":"Loading recipes..."}],"children":"$L2"}]]}]
3:I["client",[],"Paginator"]
4:{"id":"loadMore#loadMore","bound":null}
2:["$","$L3",null,{"initialItems":[["$","div","1",{"style":{"padding":12},"children":[["$","strong",null,{"children":"Pasta Carbonara"}]]}],["$","div","2",{"style":{"padding":12},"children":[["$","strong",null,{"children":"Grilled Cheese"}]]}]],"initialCursor":2,"loadMoreAction":"$h4"}]
- Row 1 — a Symbol.
$ScreatesSymbol.for('react.suspense'), which becomes the type of the Suspense element used in row 0. - Row 0 — the root element tree.
"$1"is a direct reference to the Suspense symbol from row 1."$L2"is a lazy reference — the Paginator's children that will stream in later as row 2. - Row 3 — a Client Component import. The
Iprefix tells the browser to load Paginator from its chunk. The three slots are[moduleId, asyncChunks, exportName]. - Row 4 — Server Action metadata, plain JSON with no
"$"marker. The client builds a callable stub fromid; the function body never crosses the wire. - Row 2 — the Paginator element, streamed last.
"$L3"lazily references the import from row 3;"$h4"references the action metadata from row 4.
Show the full source — why fibers can't serialize, and the handoff into the reconciler
Why Fibers cannot be serialized
// A Fiber contains circular, non-serializable references:
{
stateNode: HTMLDivElement, // DOM node — not JSON
updateQueue: {...}, // circular references
memoizedState: {...}, // hooks linked list
child: Fiber, // circular
sibling: Fiber, // circular
return: Fiber, // circular
alternate: Fiber, // circular
}
// A React Element is just a plain, serializable description:
{
$$typeof: Symbol(react.element),
type: "div",
key: null,
props: { children: "Hello" } // fully serializable
}
createFiberFromElement — Flight elements are plain objects; the same path JSX uses
function createFiberFromElement(element, mode, lanes) {
var fiber = createFiberFromTypeAndProps(
element.type, // "div" or a resolved Client Component function
element.key,
element.props, // { children: "Hello" }
element._owner,
mode,
lanes
);
return fiber;
}
Concrete payload for a server component that renders a client component
// Server Component
function Page() {
return (
<div>
<h1>Title</h1>
<LikeButton /> {/* CLIENT Component */}
</div>
)
}
// Flight payload produced:
// 1:I["./LikeButton.js",[],"default"] ← client reference (NOT host, NOT collapsed)
// 0:["$","div",null,{"children":[
// ["$","h1",null,{"children":"Title"}], ← collapsed server output (host)
// ["$","$L1",null,{}] ← hole pointing at the client component
// ]}]
//
// <Page> never appears in the payload — it collapsed to its output (the div).
// <LikeButton> is a hole — tag-0 FunctionComponent fiber that runs in the browser.