Web Engineering ·

How React Server Components Work: The Compilation Pipeline, RSC Protocol, and Architecture Behind Zero-Bundle Server Rendering

A deep technical look at how React Server Components split the module graph at use client boundaries, serialize the component tree over the Flight wire protocol, and reconstruct it on the client without shipping server code to the browser.

How React Server Components Work: The Compilation Pipeline, RSC Protocol, and Architecture Behind Zero-Bundle Server Rendering

Every few years, React ships something that changes how you reason about rendering. Hooks changed component composition. Concurrent mode changed how work is scheduled. React Server Components change where components live.

The pitch is simple: render components on the server, stream the result to the client, and never ship the server-side code to the browser. But “simple pitch” hides a surprising amount of machinery. To use RSCs well, you need to understand the compilation step that splits the module graph, the wire protocol that carries the serialized tree, and the client runtime that reconstructs and merges it with interactive components.

This article covers each layer with enough depth to reason about it in production.

The Problem RSCs Solve

The standard React SPA model creates a predictable sequence of problems as an application grows.

First, the network waterfall. The browser downloads the JavaScript bundle, executes it, renders a shell, then issues fetch requests for data, waits, and re-renders. Each async boundary adds latency. A dashboard with three independent data sources results in three sequential or parallel round trips, but even parallel fetches must all resolve before the final render is complete. The server already has a database connection and can resolve those three queries in a single round trip from the server to the database.

Second, the bundle. Every dependency imported by a data-fetching component, a markdown parser, a date formatting library, a PDF renderer, ships to the browser. Users pay the parsing and execution cost for code that only ever runs at render time on a server with a database connection. A node_modules directory that starts at 5MB becomes 15MB as the feature count grows.

Third, prop drilling for data. Components that need data either fetch it themselves (waterfall) or receive it as props from a parent that had to fetch it. Both patterns fight the component tree’s natural shape.

Traditional SSR addresses the initial render but not the bundle. With SSR, you render HTML on the server for the first load, but the same JavaScript that rendered that HTML ships to the client for hydration. The client re-runs the rendering logic to attach event handlers, even though the HTML is already correct.

RSCs address both. Server components never ship to the browser. Their output, a serialized description of the tree, travels over the wire instead. Client components hydrate as before. The two live in the same component tree, rendered in different environments.

The Compilation Step: Splitting the Module Graph

The central mechanism is the "use client" directive. When a bundler that understands RSCs processes your source code, it walks the module dependency graph and uses "use client" as a boundary marker.

Any file that starts with "use client" becomes a client entry point. The bundler creates a separate client bundle chunk for it. Every import chain that reaches a "use client" module on the server side is broken: instead of including the module’s code in the server bundle, the server gets a reference object pointing to the chunk identifier and the export name.

// server-bundle output (simplified)
// Instead of inlining InteractiveChart code, the server knows:
const InteractiveChartRef = {
  $$typeof: Symbol.for("react.client.reference"),
  $$id: "chunk-abc123#InteractiveChart",
  $$async: true,
};

The build also handles the inverse: "use server" marks functions that run on the server but can be called from the client (Server Actions). Those get compiled to network-callable stubs on the client side.

This is worth pausing on. There is no runtime module loading or dynamic import at the boundary. The bundler statically analyzes the graph at build time, splits it, and produces references. At runtime, the server never sends client component source code; it sends a reference that the client runtime knows how to resolve.

A few consequences fall out of this:

  • You cannot import a server component from a client component. The graph split is unidirectional. Server components can import client components and receive their reference objects, but client components cannot import server components because there is no server component bundle to include.
  • Third-party packages that use browser APIs need the "use client" directive at their entry, or you need to wrap them. This is a common source of confusion when migrating an existing component library.
  • The boundary is per-file, not per-component. If you have two components in the same file and one needs "use client", the whole file becomes a client entry.

The RSC Wire Format: Flight Protocol

Once the server renders the component tree, it needs to transmit the result to the client. This is the React Flight protocol, sometimes called the RSC payload.

Flight is a streaming serialization format. It is not HTML. It is a representation of the React element tree that the client-side React runtime can consume incrementally as chunks arrive.

The payload looks roughly like this in its text form:

1:I["chunk-abc123#InteractiveChart",["chunk-abc123"],"InteractiveChart"]
2:["$","div",null,{"className":"dashboard","children":["$","$L1",null,{"data":"$3"}]}]
3:{"labels":["Jan","Feb","Mar"],"values":[42,67,91]}

Each line is a numbered row. The I prefix on the first row declares a client component reference (module ID and export name). The second row is the React element tree using $ to denote React elements and $L1 to reference the client component by its row number. The third row is the resolved data that was passed as a prop.

The client runtime reads these rows as they stream in. It builds a module map from the I rows, constructs the element tree from the element rows, and substitutes data references as they arrive. Components wrapped in <Suspense> can stream their subtrees after the initial payload: the server sends a placeholder row first, finishes rendering the suspended subtree, then sends the content rows and a replacement instruction.

This is different from server-rendered HTML in an important way. HTML carries the rendered output. The Flight payload carries the element tree description. The client re-renders from that description, which means React’s reconciler takes over and can attach event handlers, manage state, and run effects exactly as it would in a normal client render. The difference is that server component subtrees appear as opaque nodes in the reconciler’s tree; React knows they are already resolved and does not attempt to re-render them on the client.

Streaming and Suspense Integration

Flight was built for streaming from the start. The server uses renderToPipeableStream or renderToReadableStream to write the payload as a Node.js stream or Web Streams API stream. Chunks are flushed as subtrees complete.

Consider a layout with a slow data-dependent section:

// app/dashboard/page.tsx (Server Component)
import { Suspense } from "react";
import { SalesSummary } from "./SalesSummary"; // Server Component
import { RecentActivity } from "./RecentActivity"; // Server Component

export default async function DashboardPage() {
  return (
    <main>
      <h1>Dashboard</h1>
      <Suspense fallback={<SalesSummarySkeleton />}>
        <SalesSummary />
      </Suspense>
      <Suspense fallback={<ActivitySkeleton />}>
        <RecentActivity />
      </Suspense>
    </main>
  );
}

// SalesSummary.tsx (Server Component, no "use client")
async function SalesSummary() {
  const data = await db.query(
    `SELECT SUM(amount) as total, COUNT(*) as count
     FROM orders WHERE created_at > NOW() - INTERVAL '30 days'`
  );
  return (
    <section>
      <p>Total: ${data.total.toLocaleString()}</p>
      <p>Orders: {data.count}</p>
    </section>
  );
}

The server flushes the outer shell immediately, then streams SalesSummary and RecentActivity as their async operations complete, in whichever order they finish. The client shows skeletons while waiting, then React replaces them as payload rows arrive. The browser sees HTML for the shell and Flight rows for the deferred subtrees, all over the same HTTP response.

No second round trip. No client-side fetch after the shell loads.

Client Components in the Tree

Client components coexist with server components in the same tree. The interaction is unidirectional but composable.

// SearchBar.tsx
"use client";

import { useState } from "react";

interface SearchBarProps {
  onSearch: (query: string) => void;
  placeholder?: string;
}

export function SearchBar({ onSearch, placeholder }: SearchBarProps) {
  const [value, setValue] = useState("");

  return (
    <input
      value={value}
      placeholder={placeholder}
      onChange={(e) => setValue(e.target.value)}
      onKeyDown={(e) => {
        if (e.key === "Enter") onSearch(value);
      }}
    />
  );
}
// ProductsPage.tsx (Server Component)
import { SearchBar } from "./SearchBar"; // client reference, not the code
import { ProductGrid } from "./ProductGrid"; // Server Component

export default async function ProductsPage({
  searchParams,
}: {
  searchParams: { q?: string };
}) {
  const products = await fetchProducts({ query: searchParams.q });

  return (
    <div>
      <SearchBar
        placeholder="Search products..."
        onSearch={async (q) => {
          "use server";
          redirect(`/products?q=${q}`);
        }}
      />
      <ProductGrid products={products} />
    </div>
  );
}

SearchBar receives a Server Action as a prop. On the client, that prop is compiled to a network stub. When the user submits a search, the client calls the server, the server rerenders the page tree, and the Flight protocol delivers the updated RSC payload. React reconciles the new tree against the existing DOM.

This is the pattern that collapses the data-fetching waterfall. The server component fetches data directly, passes it down. No useEffect, no client-side fetch, no loading state for the initial render.

RSC vs Traditional SSR vs SPA vs Islands

DimensionRSCTraditional SSRSPAIslands
Initial HTMLStreamed, incrementalFull page (or shell)Shell onlyFull page
JavaScript shippedClient components onlyFull app codeFull app codePer-island only
Data fetchingOn server, colocatedServer at page levelClient-sidePer-island
InteractivityClient componentsFull hydrationFull hydrationPer-island
State between navigationsReact reconcilerFull page reload (unless SPA-hybrid)Router stateFull page reload
Server round trips for navRSC payload (single)Full HTML responseAPI callsFull HTML response
Code splitting granularityComponent levelPage level (usually)Route levelIsland level
Framework supportNext.js App RouterAny SSR frameworkAnyAstro, Fresh
Learning curveHighMediumLowMedium
Caching storyPer-request or cached payloadFull-page cacheAPI response cacheFragment cache

Islands architecture (Astro) is the closest conceptual neighbor. Both avoid shipping unused JavaScript. The difference is that RSCs live in the React rendering model with full reconciler support, navigation without full page reloads, and Server Actions as a first-class primitive. Islands are independent; RSCs share a tree.

Traditional SSR with hydration sends the full component code to the client. RSCs send a payload that the client runtime interprets, but the server component code itself stays on the server. For a page that has 60% server components and 40% client components, the client bundle can be 60% smaller than the equivalent SSR bundle.

SPAs retain the simplest mental model and the best tooling maturity. The runtime behavior of RSCs, especially around navigation, caching headers, and partial tree updates, introduces new failure modes that are not yet well-documented across frameworks.

Production Considerations

Caching the RSC payload. The payload is cacheable. In Next.js, the fetch calls inside server components opt into a per-request cache by default. At the route level, you can set revalidate to cache the full RSC payload at the CDN edge. When a user navigates to a cached route, they get the payload from the edge, not from your server. This is a significant win for read-heavy routes.

// Cache this route's RSC payload for 60 seconds at the CDN
export const revalidate = 60;

Prop serialization constraints. Props passed from server components to client components must be serializable over the Flight protocol. Functions (other than Server Actions) cannot be serialized. Class instances cannot be serialized. This means your data transfer objects at the server/client boundary must be plain JSON-compatible values. The compiler will catch most violations at build time, but not all.

// This will fail at runtime: Date object passed to a client component
// Pass the ISO string instead
<ClientChart data={{ timestamp: new Date() }} /> // bad
<ClientChart data={{ timestamp: new Date().toISOString() }} /> // good

Error boundaries behave differently. A server component that throws during rendering causes the nearest Suspense boundary to show the fallback and the error to propagate. You should wrap independent data-loading subtrees in both <Suspense> and <ErrorBoundary> so a single slow or failing query does not block the entire page.

The “use client” boundary is not a security boundary. A client component rendered inside a server component still runs in the browser. Do not pass secrets, tokens, or sensitive database rows as props to client components expecting them to stay private. The Flight payload is inspectable in DevTools.

Navigation in Next.js App Router. Client-side navigation triggers a fetch to the server for the RSC payload of the destination route. This is a full re-render of the destination’s server components. Shared layouts that do not change are excluded from the payload via the segment caching model. Understanding which segments are cached and which trigger fresh server renders is critical for optimizing Time to Interactive on navigation.

Bundle analysis changes. Standard bundle analyzers like @next/bundle-analyzer show only client bundles. You will not see server component sizes in the output, because they do not ship. What you will see is which client components pulled in which third-party dependencies. If a library is larger than expected in the client bundle, check whether it needs the "use client" directive or can be moved to server-only code.

The Mental Model That Sticks

A useful mental model: server components are glorified template functions that run on the server and emit a serializable description of the tree. Client components are the same React components you have always written. The Flight protocol is the wire format that carries the server-rendered description to a client runtime that knows how to interpret it.

The module graph split happens at build time. The serialization happens at request time. The reconciliation happens in the browser. Each step is independently cacheable and independently debuggable.

The complexity is real. The boundary rules, the prop serialization constraints, the caching semantics, and the navigation model all require deliberate mental effort. But the payoff, shipping only the JavaScript that interactive components need, fetching data where the database connection lives, and streaming the tree incrementally, addresses problems that have accumulated in the SPA model for years.

Understanding the machinery makes the tradeoffs tractable. When a boundary violation produces a cryptic error, you know to look at the module graph. When a client component renders stale data, you know to check the RSC payload cache. When bundle size is still large after migrating to the App Router, you know to trace which "use client" entry points are pulling in heavy dependencies.

The protocol is not magic. It is a build-time graph split, a streaming serialization format, and a client runtime that knows how to consume it.

More in Web Engineering

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
Web Engineering ·

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement

A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
Web Engineering ·

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js

A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
Web Engineering ·

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format

A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
Web Engineering ·

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit

A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.