React: RSC Internals Deep Dive

An in-depth look at the internals of React Server Components (RSC), exploring how they work under the hood and their impact on modern web development.

React Web Development Frontend

How React Server Components Work (React 19+ Deep Dive)

React Server Components (RSC) in React 19 turn “rendering” into a distributed protocol between a server runtime and the browser, instead of a single pass that only runs in the client or on an SSR server. This post walks through the internals: compilation, the React Flight protocol, streaming, and how client and server trees reconcile at runtime.

Mental model: two Reacts, one tree

At a high level, RSC splits your app into two module graphs that cooperate to render a single React element tree.

  • The RSC server renderer runs in a Node/edge/runtime process and executes server components only to produce a serialized “virtual tree diff” called the Flight payload.
  • The client renderer in the browser receives HTML + this Flight payload and uses it to build and update the in-memory React tree, just like it would after hydration.

The key idea: server components never ship their code to the browser; only their results (elements, props, module references, and placeholders) cross the wire.

// app/page.tsx — a Server Component (default in React 19 frameworks)
import Products from './Products';
import Cart from './Cart';

export default async function Page() {
  const products = await loadProducts(); // DB, filesystem, internal APIs
  return (
    <main>
      <Products products={products} />
      <Cart />
    </main>
  );
}

In this example, Page, Products, and Cart may be pure server components; only components explicitly marked with "use client" (and their transitive imports) participate in the client bundle.

Compilation: building dual module graphs

Before any request hits your app, the bundler (Webpack, Vite, Metro, etc.) scans your modules and builds separate graphs for server and client.

  • Any file containing the "use client" directive at its top becomes a client entry, and everything it imports (unless explicitly treated as shared/“isomorphic”) is bundled for the browser.
  • Everything else is assumed to be a server component module and is compiled for the RSC server runtime only; it is not directly executable in the browser.

Conceptually you end up with:

Server graph:  RSC entry → (can import other server modules and *client boundaries*)
Client graph:  Client entries → (can import only client/isomorphic modules)

Where “client boundary” means: the server graph references a client component by a stable module reference ID that the client graph knows how to resolve. This ID ends up serialized into the Flight payload so the browser can reconstitute that part of the tree with actual interactive components later.

The Flight protocol: RSC’s wire format

The bridge between the RSC server and the browser is a streaming serialization format informally called React Flight.

The Flight payload is:

  • A stream of records (usually text or binary frames) describing:
    • React elements (type, props, children references).
    • Placeholders for promises (async components / data).
    • References to client components (by module ID + export name).
    • Primitive values and props.
  • Incrementally emitted as the RSC tree resolves, so the browser can start patching the UI before everything is done.

Roughly, the server renderer walks your RSC tree and for each node emits something like:

R <row-id> <react-element: type-ref, props-ref, children-refs>
M <row-id> <module-ref: client-module-id, export-name>
P <row-id> <primitive-value>
S <row-id> <suspense-placeholder/promise>
...

The exact byte-level protocol is private and unstable, but the semantics match this idea. On the client, React’s Flight runtime parses records into models, then converts models into React elements with internal functions (like parseModelString in the React codebase).

Request lifecycle: from HTTP to DOM

A typical document request that uses RSC in React 19 has two intertwined streams: HTML and Flight.

  1. Handle request & run RSC entry The framework’s SSR entry calls into React’s RSC renderer with the root server component and the request context.
    • The RSC renderer executes server components, resolves async data, and writes Flight records to a stream.
  2. SSR HTML shell (optional but common) In parallel or after an initial RSC render, the framework runs a separate render that turns the React tree (informed by RSC output) into HTML for the initial document.
    • This is where the classic SSR/hydration model still exists: HTML is streamed to the client; a <script> bootstraps React on the client.
  3. Stream Flight to the browser The Flight payload is either:
    • Inlined into the HTML as a script tag that the client runtime can pick up, or
    • Served over a dedicated endpoint (e.g., /react or per-route RSC endpoint) that the client-side router fetches.
  4. Client: load Flight, resolve boundaries In the browser:
    • The client React runtime parses the Flight stream into a React element model.
    • Wherever Flight points at a client component module ID, the bundler runtime dynamically imports the corresponding JS chunk.
    • React uses the element model as the “authoritative tree” and either:
      • Hydrates existing HTML, or
      • Updates the DOM if this Flight payload is an update for an already-mounted tree.

The result is a single logical React tree that was partially produced on the server and partially produced in the browser, with the server side specializing in data/markup and the client side specializing in interactivity.

Server vs client components at runtime

With internals in mind, the rules that frameworks expose make more sense:

  • Server components can import client components.
    • At compile time, this creates a client boundary in the RSC graph and a module reference in Flight.
    • At runtime, the server never executes the client component; it only emits a placeholder + module ID for the client to fill in.
  • Client components cannot import server components.
    • The server component code never ships to the browser, so there is nothing to import there.
    • Trying to do so breaks the model: the client cannot execute code that only exists on the server graph.
  • Isomorphic modules (utilities, schemas, simple logic) are safe to import from both sides as long as they are pure and do not depend on node-only or browser-only APIs.

Example: crossing the boundary

// app/actions.ts — server-only module
export async function addToCart(productId: string) {
  'use server';
  // DB write, auth checks, etc.
}

// app/CartButton.tsx — client component
'use client';

import { useTransition } from 'react';
import { addToCart } from './actions';

export function CartButton({ productId }: { productId: string }) {
  const [pending, start] = useTransition();

  return (
    <button
      disabled={pending}
      onClick={() => start(() => addToCart(productId))}
    >
      {pending ? 'Adding…' : 'Add to Cart'}
    </button>
  );
}

Here, the server action addToCart is referenced from a client component, but what crosses the boundary in Flight is not the function body; it is a special reference that the client runtime uses to call back into the server. The server action’s implementation still only exists in the server graph.

Streaming, Suspense, and concurrent updates

RSC leans heavily on Suspense and streaming to keep latency hidden.

  • When a server component throws a promise (e.g., await in an async component), the RSC renderer emits:
    • A placeholder record for that part of the tree, and
    • Later, a fulfillment record once the promise resolves.
  • Suspense boundaries on the client map to these records:
    • The client shows fallback UI while waiting for corresponding Flight chunks.
    • Once data arrives, React replaces that part of the tree with the resolved elements in a concurrent-safe way.

Crucially, server components are never hydrated in the browser. Only client components are hydrated; server components appear as static DOM whose logical representation in React’s tree is reconstructed from Flight records rather than from re-running their render functions on the client.

Putting it together in a custom stack

To support RSC without a meta-framework, your stack must provide:

  • A bundler integration that:
    • Understands "use client" and builds separate server/client graphs with matching module IDs.
    • Exposes manifest data mapping module references to client chunks.
  • An RSC server entry that:
    • Receives a request and calls the React RSC APIs to render a root server component into a Flight stream.
    • Streams that Flight payload over HTTP (either inlined into HTML or via a dedicated endpoint).
  • A document/SSR entry that:
    • Uses React DOM’s SSR APIs to render an HTML shell that bootstraps the client runtime and attaches to containers that will receive RSC-driven updates.
  • A client bootstrap that:
    • Initializes React with a router that knows how to fetch and apply Flight payloads for navigation and server-driven updates.

Conclusion

React Server Components represent a fundamental shift in how we build web apps, distributing rendering work between server and client in a way that optimizes for performance and developer experience. By understanding the internals—compilation, the Flight protocol, streaming, and runtime reconciliation—you can better leverage RSC in your own projects and even build custom frameworks around it.