React Server Components: The Mental Model & the Flight Protocol
RSC is not server-side rendering — it is a serialized component tree wire format (React Flight) that separates data ownership from interactivity ownership at the component boundary, not the page boundary.
Modern Web Rendering & Performance
React Server Components: The Mental Model & the Flight Protocol
Rendering is not output — it is a scheduling contract. Every boundary you draw between server and client is a promise to the browser's scheduler about which work happens where, and when that boundary is drawn in the wrong place, the browser pays for it twice: once on the server and once on the client. Most React applications violate this contract at the data layer — not because of bad code, but because the only tool available for years was useEffect, and useEffect is a client-side escape hatch masquerading as a data acquisition primitive.
React Server Components (RSC) change this contract at its root. RSC is not a rendering mode — it is a wire protocol. The React Flight format separates data ownership (server) from interactivity ownership (client) at the component boundary, and transmits the resolution of that separation as a serialized component tree, not as HTML. Understanding why this wire format is necessary is what separates engineers who use RSC from engineers who understand it.
1. The Broken Pattern: useEffect as a Data Acquisition Layer
Here is code that every React engineer has written:
This pattern has three compounding costs that are invisible until you measure them:
-
The double-fetch: If this page is server-side rendered, the HTML is generated on the server — but
useEffectnever runs on the server. The server sends HTML with a skeleton. The browser hydrates React. ThenuseEffectfires. The product data is fetched again, from the client. You paid the network cost twice. -
The waterfall: If
ProductDetailitself renders a sub-component that also fetches data in auseEffect, that fetch cannot start until the parent's fetch completes and React re-renders. Each level of the component tree adds a sequential round-trip. A three-level deep component hierarchy means three sequential network requests where one would suffice. -
The skeleton flash: Even with SSR, the user sees a skeleton on first load because the server had no way to embed the data in the HTML — it didn't know the data at render time. The hydrated component immediately shows a loading state before the client fetch completes.
Wrapping a useEffect data fetch in Suspense does not fix this. Suspense with a use(promise) call inside a Client Component still executes that fetch on the client after hydration. The component boundary — server vs. client — is what determines where data is fetched, not the presence of Suspense.
The correct diagnosis: useEffect is the wrong tool for data ownership. The correct tool is a Server Component.
2. The RSC Model: Data Ownership at the Boundary
React Server Components introduce a first-class concept that was previously missing from React: a component that runs only on the server and never ships its JavaScript to the browser.
The key insight is what does not happen: no useEffect fires on the client. No skeleton renders while waiting for data. The server fetches the product, passes it as props to ProductDetail, serializes the resolved component tree, and sends it to the browser. The browser receives a tree that already has the data — it does not re-fetch.
2.1 Why This Boundary is a Build-Time Artifact
The Server/Client component boundary is not determined at runtime by a flag or a context value. It is determined at build time by the bundler (Webpack or Turbopack) reading the 'use client' directive.
'use client' does not mean "this component runs only on the client." It means "this component is the root of a client subtree." Client Components are still server-side rendered for the initial HTML — but their JavaScript is also shipped to the browser for hydration and interactivity. Server Components ship zero JavaScript to the browser.
The bundler constructs two separate module graphs from the 'use client' boundaries:
| Bundle | Contains | Can Use |
|---|---|---|
| Server bundle | All Server Components, their imports | Node.js APIs, DB clients, process.env, file system |
| Client bundle | All Client Components and everything they import | useState, useEffect, browser APIs, event handlers |
Anything imported by a Server Component that is not marked 'use client' stays server-only and is never included in the client bundle.
3. The React Flight Protocol
The mechanism that makes RSC work is not magic — it is a wire format called React Flight. Understanding Flight is what lets you reason about RSC behavior in production rather than cargo-culting 'use client' boundaries.
3.1 What Flight Is Not
Flight is not HTML. When a Server Component renders and its output is sent to the browser, the browser does not receive HTML strings. It receives a custom serialized format — a compact representation of the resolved component tree.
Flight is not JSON. It is a streaming text protocol with a line-per-row format that encodes references, components, props, and lazy-loaded chunks as separate rows. The client runtime reads these rows and reconstructs the React tree.
3.2 The Wire Format
A simplified React Flight payload for a Server Component that renders a product card looks like this:
The "$" prefix identifies React element markers. Each row is a self-contained unit that the Flight decoder can process as it arrives over the stream — before all rows have been received.
3.3 Client Component References in the Flight Payload
When a Server Component renders a Client Component, the Flight encoder does not inline the Client Component's implementation into the payload. Instead, it inserts a reference to the Client Component's chunk ID — the module identifier that the bundler assigned to that component.
The Flight payload contains a reference like "@1" for the Client Component, with the props serialized alongside it. The browser's Flight decoder sees this reference, looks up the chunk in the loaded client bundle, and hands the props to React for rendering.
Think of Flight as a diff applied to the component tree, not a full page render. The server describes what the tree looks like (structure + data). The client knows how to make it interactive (event handlers + state). Flight sends the former; the client bundle provides the latter.
4. use client and use server Directives
4.1 use client — The Module Boundary Marker
'use client' is a module-level directive placed at the top of a file. It is processed by the bundler at build time, not by the React runtime at runtime.
Every module imported by a 'use client' file is pulled into the client bundle — including its transitive dependencies. A Client Component that imports a large server-only library (e.g., a database ORM) will cause a build error. The bundler enforces that server-only imports cannot be reached from the client graph.
4.2 Composition Constraints: The Slot Pattern
A Server Component can render a Client Component by importing and using it directly. The reverse is not allowed by the bundler:
The correct pattern for passing server-fetched content through a Client Component is the slot pattern (children):
Similarly, React.lazy cannot wrap a Server Component — lazy loading is a client-side code-splitting mechanism, and Server Components have no client-side bundle to split:
5. The RSC Cache Model and cache()
5.1 Automatic fetch Deduplication
In Server Components, React 19 extends the native fetch API with automatic per-request memoization. Concurrent calls to fetch with the same URL and options within the same server render pass are deduplicated — only one actual HTTP request is made, and the result is shared:
This deduplication is per-render-pass only. It does not persist across different requests. For cross-request caching (e.g., caching a database query result for 60 seconds), use the cache() function from React or Next.js's data cache.
5.2 The cache() API for Cross-Render Persistence
React 19's cache() function memoizes a function's return value across the lifetime of a server request — and across multiple render passes of the same request when used with Next.js's extended data cache:
| Mechanism | Scope | Use Case |
|---|---|---|
fetch auto-memoization |
Per render pass, same URL | Shared API calls across sibling Server Components |
cache() from React |
Per request, same arguments | Shared DB queries, avoids N+1 across component tree |
| Next.js data cache | Cross-request, configurable TTL | Full-page ISR, shared data across users |
6. Error and Loading Boundaries
6.1 <Suspense> with Server Components
<Suspense> in an RSC context works differently from client-side Suspense. When a Server Component awaits data, React does not wait for all data before sending anything — it suspends that branch and continues rendering the rest of the tree, sending completed chunks to the browser via the Flight stream.
The browser receives the <StaticHeader> immediately in the Flight stream. The two <Suspense> boundaries render as their respective skeletons until their Server Components resolve — at which point the resolved content arrives as a new Flight chunk and React replaces the fallback.
Place <Suspense> boundaries as close as possible to the async data dependency, not at the page level. A page-level <Suspense> delays the entire page until the slowest component resolves. Granular boundaries allow fast components to render immediately.
6.2 <ErrorBoundary> and Flight Stream Errors
If a Server Component throws during rendering, the error propagates through the Flight stream to the nearest <ErrorBoundary> on the client:
In production, the error message from a thrown Error in a Server Component is not forwarded to the client (it may contain sensitive server-side details). Only a generic "An error occurred" is sent. The full error is logged server-side. Use error.digest to correlate client-visible errors with server logs.
Summary
| Concept | Rule |
|---|---|
| RSC vs. SSR | RSC is not a rendering mode — it is a data ownership model. Server Components eliminate client-side re-fetches; SSR reduces TTFB. They compose. |
| Flight protocol | Flight transmits a serialized component tree, not HTML. The client reconstructs the tree without re-fetching any data. |
use client directive |
Module-level marker processed by the bundler — defines the root of a client subtree. Client Components are SSR'd for the initial HTML but also ship JavaScript to the browser. |
| Composition rule | Server Components can render Client Components. Client Components cannot import Server Components — use the slot/children pattern. React.lazy cannot wrap Server Components. |
fetch deduplication |
Concurrent fetch calls with the same URL in the same render pass are deduplicated automatically. Use cache() for cross-render or cross-request persistence. |
<Suspense> streaming |
Async Server Components suspend their branch and stream their resolved output as a Flight chunk — the rest of the tree renders immediately without waiting. |
What's Next
In Part 2, we cover Server Actions — the
"use server"counterpart that exposes a POST endpoint at the function level, with typedFormDataextraction,useActionStatefor validation feedback, and the progressive enhancement contract that makes forms work without JavaScript.server-actions-partial-prerendering-rsc→
This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.