What you'll learn
- Why server rendering is old, but the server's role in React is new
- The two server renderers, Fizz and Flight, and why neither uses the reconciler
- How to read the RSC payload that Server Components produce
- What
'use client'really marks, and why client components still run on the server - How Suspense streams a page and fills it in before React's JavaScript loads
Modern React does a large part of its work before the browser runs a single line of your JavaScript. This article walks through what actually happens on the server: which engines run, what they send down the wire, and how a half-finished page fills itself in while it loads.
Server rendering is old; the server's role is new
React could render on the server almost from day one. renderToString shipped in the early releases around 2013, and it did one thing: turn a component tree into a single HTML string.
The catch was that it was all-or-nothing. If one component waited 3 seconds on a database, the whole response waited 3 seconds, and the user stared at a blank tab.
Two releases changed the picture:
| Release | Year | What it added on the server |
|---|---|---|
| React 18 | 2022 | Streaming HTML with Suspense, plus selective hydration. The page can leave the server in pieces. |
| React 19 | 2024 | Stable Server Components and Server Actions. Some components now run only on the server and never ship their code. |
In other words, server rendering itself is old news. What has changed is the share of the work React now hands to the server.
The server has its own engines
It's tempting to picture one React engine running in both places. In reality there are three. In the browser, React's work is done by react-reconciler, the fiber-based engine that schedules updates. On the server, the react-server package brings two separate renderers:
- Fizz (
ReactFizzServer.js) renders components to HTML. - Flight (
ReactFlightServer.js) renders Server Components to a text stream called the RSC payload.
Open either file and you won't find an import of the reconciler, or a single fiber.
That's deliberate. The reconciler is built for a long-lived UI that keeps changing: it juggles state updates, priorities and paused work. A server request is a one-shot render that ends with the response, so all that scheduling logic would only slow it down.
You can see the difference in how hooks behave:
| Hook | In Fizz (SSR) | In Flight (Server Components) |
|---|---|---|
useState | Returns the initial value only | Throws: not supported in Server Components |
useEffect / useLayoutEffect | Does nothing | Throws: not supported in Server Components |
Put simply, your components travel between environments, but the runtime underneath them doesn't. The browser and the server each bring their own.
Key takeaway
React on the server uses two dedicated renderers, Fizz for HTML and Flight for the RSC payload, and neither uses the browser's reconciler.
Server Components speak a wire format, not HTML
A Server Component runs only on the server. It can be async, query a database directly, or import a heavy library, and none of that code reaches the browser.
What it produces isn't HTML, though. Flight turns it into the RSC payload: a newline-separated stream of rows, each shaped id:data. Take a product page that renders a heading and an interactive AddToCart button:
1:I["./AddToCart.js",["cart-chunk"],"AddToCart"]
0:["$","section",null,{"children":[["$","h2",null,{"children":"Ceramic mug"}],["$","$L1",null,{"sku":"MUG-01"}]]}]How to read it:
["$", type, key, props]is a React element. The$stands in forREACT_ELEMENT_TYPE.- An
Irow is an import. It tells the client which module and chunk contain a client component. Its exact shape depends on your bundler. $L1is a lazy reference to row 1. The client component isn't rendered here; the server only leaves its address.
Think of $L1 as a placeholder with a return address: the server reserves the spot, and the browser plugs in the real component when its chunk shows up. Making it lazy means React can suspend on that one component instead of its parent while the chunk loads.
Async data follows the same idea. An unresolved promise is written as $@ plus a row id, and its value arrives later in its own row. The structure goes out first; slow parts follow.
It also explains why your framework complains when you pass a function prop from a Server Component to a client component. Props are written into the stream as text, so anything that can't become text can't cross that line. Functions can't be written into it, with one exception: a Server Action, which is sent as a reference the client can call back.
Key takeaway
Server Components output a serialized tree, which is why their props to client components must be serializable.
'use client' marks a boundary, not a location
The name suggests "this code runs only in the browser." That isn't what it means. 'use client' tells the bundler where a module boundary sits.
When the server build hits that boundary, the bundler swaps the module's exports for client references, using helpers such as registerClientReference. Those references are what Flight writes out as I rows.
What catches many developers off guard is that client components run on the server too. They just go through Fizz instead of Flight. On the first load they become HTML, then hydrate in the browser.
So in a framework like Next.js, a single request passes through both renderers:
- Flight runs the Server Components and produces the RSC payload.
- Fizz takes that payload, plus the client components, and renders HTML.
- The browser receives the HTML with the payload inlined in
<script>tags, so it never has to fetch it a second time.
This is where the bundle savings come from. With Server Components, only HTML and the payload reach the browser, so heavy server-only code costs nothing on the client. With classic SSR, every component's JavaScript still ships, because the browser needs it to hydrate.
Key takeaway
'use client' marks a bundler boundary. Client components still render to HTML on the server.
For the practical side of choosing where each component runs, see Server vs Client Components.
HTML or payload: two outputs, two readers
The server can produce two things, and they aren't rivals. Each has a different consumer.
| Output | Made by | Read by | Strength | Limit |
|---|---|---|---|---|
| HTML | Fizz | The browser | Paints instantly, no JavaScript needed | Once on screen, React can't merge new HTML in without losing state |
| RSC payload | Flight | React | A description of the tree that React can reconcile with what's on screen | Needs React running in the browser |
Because the payload is reconciled rather than swapped in, input values and client component state survive an update.
You rarely choose between them by hand; the framework does that. The question worth asking is whether your project benefits from Server Components in the first place.
Fizz alone (static or SSR) is enough when pages are mostly read and a full page load per click is acceptable: blogs, documentation sites, marketing and landing pages.
Flight earns its keep when the app stays alive between clicks, or when server-side code is heavy:
- Syntax highlighting with Shiki. Grammars, themes and the WASM regex engine stay on the server; the browser gets colored
<span>s. Classic SSR would ship all of it just to hydrate. - Markdown with remark and rehype. The parser and its plugins never enter the bundle, only the elements they produce.
- Dashboards. Components read the database directly, while filters, tabs and open panels survive navigation.
Choosing between static, server and client rendering for a page is its own decision: SSR, SSG, ISR, or CSR?
First load and navigation take different paths
Take an app whose header and sidebar stay put while the main area swaps from page to page.
On the first load, the browser has nothing yet, so both renderers run. Flight builds the payload, Fizz turns it into HTML, the browser paints, and React hydrates using the payload already inlined in the page.
On a link click, no new HTML document is requested. The router fetches the payload for the next page, so only Flight runs. React reconciles it against the current tree. The layout hasn't changed, so it stays put: header, sidebar, scroll position, even half-typed text in a search box. Only the inner page is replaced.
Classic SSR (TanStack Start's default, for example) keeps the layout alive too, but does the work in a different place. On first load only Fizz runs, and the browser must download every component's JavaScript before hydrating. On navigation, a route loader fetches serialized data and the browser renders the new page with that page's code.
Request flow · classic SSR vs Server Components
Classic SSR
First load
- Fizz renders HTML
- Browser paints
- Download all component JS
- Hydrate
Link click
- Route loader fetches data
- Load the page’s code
- Browser renders new page
Server Components
First load
- Flight builds payload
- Fizz renders HTML
- Browser paints
- Load client component JS
- Hydrate with inlined payload
Link click
- Router fetches payload (Flight only)
- React reconciles the tree
- Layout state kept
Compare the two first-load rows: the Server Components path has no "download all JS" step. Client component code still loads, but Server Components never ship, and navigation delivers a rendered tree rather than data plus the code to render it. You get the smooth navigation of a single-page app while the rendering itself stays on the server.
Key takeaway
First loads use both renderers; client-side navigation uses only Flight, and the layout's state survives.
Streaming with Suspense, step by step
Streaming is the most interesting piece of the puzzle. Follow one page from the server to the screen: an article whose comments come from a slow API.
export default function ArticlePage() {
return (
<article>
<h1>Why we moved to the edge</h1>
<Suspense fallback={<p>Loading comments…</p>}>
<Comments />
</Suspense>
</article>
);
}
async function Comments() {
const comments = await api.getComments(); // takes ~2 seconds
return (
<ol>
{comments.map((c) => <li key={c.id}>{c.body}</li>)}
</ol>
);
}1. The shell goes out immediately
Fizz renders ArticlePage. When it reaches Comments, the data isn't ready. Rather than wait, it writes the fallback and keeps going:
<article>
<h1>Why we moved to the edge</h1>
<!--$?--><template id="B:0"></template>
<p>Loading comments…</p>
<!--/$-->
</article>(Line breaks added for readability; the real output is a single line.)
<!--$?-->opens the boundary.$?means pending: content will follow.<template id="B:0">is an invisible bookmark. Comments can't carry anid, sogetElementByIdcan't find them. The template gives the spot an address, and it always sits right after the opening comment.<p>Loading comments…</p>is the fallback, plain HTML.<!--/$-->closes the boundary.
Everything sent so far is called the shell. The HTTP response stays open while the server keeps working, and because browsers parse HTML as it arrives, the user already sees the headline and the loading message.
2. The real content arrives at the end
About two seconds later, the comments resolve. Fizz renders them and appends a new chunk to the same response:
<div hidden id="S:0">
<ol><li>Great write-up!</li></ol>
</div>
<script>$RC("B:0", "S:0")</script>The content lands at the bottom of the document, hidden so it never flashes in the wrong spot. $RC("B:0", "S:0") reads as "complete boundary B:0 using the content in S:0." The first time it's needed, the script also carries the definition of $RC; React remembers what it already sent, so later boundaries get only the one-line call.
3. $RC queues the swap instead of doing it
A simplified version of what $RC does:
function completeBoundary(boundaryId, contentId) {
const content = document.getElementById(contentId); // hidden div S:0
const bookmark = document.getElementById(boundaryId); // template B:0
const marker = bookmark.previousSibling; // <!--$?-->
marker.data = "$~"; // queued: content has arrived
window.$RB.push(bookmark, content); // add to the reveal batch
// schedule the reveal: next frame if first, else ~300ms after the last one
}Swapping instantly sounds faster, but picture ten sections resolving 50 ms apart: the layout would shift ten times in half a second. React batches them and reveals at most every 300 ms (FALLBACK_THROTTLE_MS). If that delay would push you past a 2.5-second Largest Contentful Paint, React shortens it; the constant in the source is named TARGET_VANITY_METRIC.
4. $RV performs the reveal
When the timer fires, $RV walks the batch. Simplified:
function revealCompletedBoundaries(batch) {
for (let i = 0; i < batch.length; i += 2) {
const bookmark = batch[i];
const content = batch[i + 1];
const parent = bookmark.parentNode;
const marker = bookmark.previousSibling; // <!--$~-->
content.remove(); // take the hidden div out
// delete the template and the fallback, up to <!--/$-->
let node = bookmark;
while (!(node.nodeType === Node.COMMENT_NODE && node.data === "/$")) {
const next = node.nextSibling;
parent.removeChild(node);
node = next;
}
// move the real content in, just before <!--/$-->
while (content.firstChild) parent.insertBefore(content.firstChild, node);
marker.data = "$"; // completed
}
}The real code also handles boundaries nested inside fallbacks. After the swap, the DOM reads:
<article>
<h1>Why we moved to the edge</h1>
<!--$--><ol><li>Great write-up!</li></ol><!--/$-->
</article>The loading text has vanished, but the comment markers stay behind on purpose: hydration uses them to locate each boundary.
The comment protocol
Each boundary's opening comment works as a status flag that both sides understand:
| Marker | State |
|---|---|
<!--$?--> | Pending: content will stream later |
<!--$~--> | Queued: content arrived, waiting to be revealed |
<!--$--> | Completed |
<!--$!--> | Server render failed; the client will render it instead |
There are variants beyond these. When new content depends on a stylesheet, React uses $RR instead of $RC and waits for the CSS before revealing, so users never see unstyled content.
All of this happens before React loads
$RC and $RV are a few lines of plain DOM code inlined in the HTML. Readers see the article's comments even while your JavaScript bundle is still downloading.
If React is already hydrating and waiting on a boundary, it leaves a callback named _reactRetry on the comment node. When $RV reveals that boundary, it schedules the callback for the next frame:
if (marker._reactRetry) requestAnimationFrame(marker._reactRetry);This is the moment the baton passes from the server's streamed HTML to React running in the browser, which is where hydration begins.
Key takeaway
Suspense streaming sends a shell first, then fills each boundary with a tiny inline script, before React's own JavaScript has loaded.
Next up in this series: hydration, and how React adopts the server's HTML without re-rendering it.
Check your understanding
Which engine turns Server Components into the RSC payload?
Pick an answer, or press A, B, C on your keyboard.
Recap
What to remember from this article
Sections read
0
Questions
0
none answered yet
Quiz
0
not taken yet
Up next: Update Content Without Redeploying · 19 min
Continue