Siddhant Deval
Siddhant Deval
frontend5 min read

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.

Series·Part 1 of 9

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:

TYPESCRIPT
// ❌ Broken pattern — data fetched on the client after hydration
'use client'

interface Product {
  id: string
  name: string
  price: number
}

export function ProductPage({ productId }: { productId: string }) {
  const [product, setProduct] = useState<Product | null>(null)
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    fetch(`/api/products/${productId}`)
      .then(res => res.json())
      .then(data => {
        setProduct(data)
        setLoading(false)
      })
  }, [productId])

  if (loading) return <Skeleton />
  return <ProductDetail product={product!} />
}

This pattern has three compounding costs that are invisible until you measure them:

  1. The double-fetch: If this page is server-side rendered, the HTML is generated on the server — but useEffect never runs on the server. The server sends HTML with a skeleton. The browser hydrates React. Then useEffect fires. The product data is fetched again, from the client. You paid the network cost twice.

  2. The waterfall: If ProductDetail itself renders a sub-component that also fetches data in a useEffect, 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.

  3. 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.

TYPESCRIPT
// The root cause: useEffect is a browser-side escape hatch
// It has no equivalent on the server — there is no server-side "after render" phase
// SSR renders once and sends HTML; it cannot pause to wait for effects
Performance / Safety Warning

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.

TYPESCRIPT
// ✅ Correct pattern — Server Component owns data, ships nothing to the browser
// No 'use client' directive = this is a Server Component by default

interface Product {
  id: string
  name: string
  price: number
}

// This function runs on the server. Its implementation never reaches the browser.
async function getProduct(id: string): Promise<Product> {
  // Direct database query — no API route needed, no credentials exposed
  const product = await db.products.findUnique({ where: { id } })
  if (!product) notFound()
  return product
}

export default async function ProductPage({ params }: { params: { id: string } }) {
  // await directly in the component — no useEffect, no useState
  const product = await getProduct(params.id)

  return <ProductDetail product={product} />
}

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.

TYPESCRIPT
// Without 'use client' — Server Component (default)
// This module is included in the server bundle only
// It can import Node.js APIs, database clients, file system modules
export async function DataComponent() {
  const data = await fetchFromDatabase()
  return <div>{data.value}</div>
}

// ---

// With 'use client' — Client Component
// This module is included in BOTH the server bundle (for SSR) AND the client bundle
// It can use useState, useEffect, browser APIs, event handlers
'use client'
export function InteractiveButton({ onClick }: { onClick: () => void }) {
  const [clicked, setClicked] = useState(false)
  return <button onClick={() => { setClicked(true); onClick() }}>Click</button>
}
Crucial Requirement

'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:

0:["$","div",null,{"className":"product-card","children":[
  ["$","h1",null,{"children":"Acme Widget"}],
  ["$","p",null,{"children":"$24.99"}],
  ["$","button","btn-add",{"data-id":"prod_123","children":"Add to Cart"}]
]}]

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.

TYPESCRIPT
// Server Component — renders a Client Component
export default async function ProductPage({ params }) {
  const product = await getProduct(params.id)

  // AddToCartButton is a 'use client' component
  // Flight does NOT inline its implementation — it references its bundle chunk
  return (
    <div>
      <h1>{product.name}</h1>
      <AddToCartButton productId={product.id} price={product.price} />
    </div>
  )
}

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.

Mental Model Check

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.

TYPESCRIPT
// ✅ Correct placement — top of the module file, before any imports
'use client'

import { useState } from 'react'
import { formatPrice } from '@/lib/format' // This import is now also client-bundled

export function AddToCartButton({ productId, price }: { productId: string; price: number }) {
  const [added, setAdded] = useState(false)

  return (
    <button
      onClick={() => setAdded(true)}
      className={added ? 'btn-success' : 'btn-primary'}
    >
      {added ? '✓ Added' : `Add for ${formatPrice(price)}`}
    </button>
  )
}
TYPESCRIPT
// ❌ Common mistake — 'use client' inside a component or after imports
import { useState } from 'react' // Too late — directive must be first
'use client'                      // This is NOT processed correctly as a directive
Performance / Safety Warning

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:

TYPESCRIPT
// ❌ Forbidden — Client Component cannot import a Server Component
'use client'
import { ServerDataComponent } from './server-data' // Build error

export function ClientWrapper() {
  return <ServerDataComponent /> // This violates the module graph boundary
}

The correct pattern for passing server-fetched content through a Client Component is the slot pattern (children):

TYPESCRIPT
// ✅ Correct — Server Component passes server content as children prop
// Server Component (no directive)
export default async function Layout({ children }: { children: React.ReactNode }) {
  const user = await getCurrentUser()

  return (
    <InteractiveShell username={user.name}>
      {children} {/* Server-rendered content passes through as an opaque prop */}
    </InteractiveShell>
  )
}

// Client Component — receives server content as opaque children
'use client'
export function InteractiveShell({ username, children }: { username: string; children: React.ReactNode }) {
  const [menuOpen, setMenuOpen] = useState(false)
  return (
    <div>
      <nav onClick={() => setMenuOpen(v => !v)}>{username}</nav>
      <main>{children}</main>
    </div>
  )
}

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:

TYPESCRIPT
// ❌ Forbidden — React.lazy cannot import a Server Component
const LazyServer = React.lazy(() => import('./ServerComponent')) // Build error

// ✅ React.lazy is valid only for Client Components
'use client'
const LazyChart = React.lazy(() => import('./HeavyChart')) // ✅ Client Component

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:

TYPESCRIPT
// Both of these components call the same endpoint
// In the same render, only ONE fetch request is made
async function Header() {
  const user = await fetch('/api/me').then(r => r.json()) // Fetch A
  return <UserAvatar name={user.name} />
}

async function Sidebar() {
  const user = await fetch('/api/me').then(r => r.json()) // Deduplicated — reuses Fetch A's result
  return <UserMenu role={user.role} />
}
Architectural Note

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:

TYPESCRIPT
import { cache } from 'react'

// Wrapped function is memoized: same arguments → same result, computed once
const getProduct = cache(async (id: string): Promise<Product> => {
  console.log(`Fetching product ${id}`) // Logs only once even if called 10 times with the same id
  return db.products.findUnique({ where: { id } })
})

// Both components call getProduct('prod_123') — database queried once
export async function ProductTitle({ id }: { id: string }) {
  const product = await getProduct(id)
  return <h1>{product.name}</h1>
}

export async function ProductPrice({ id }: { id: string }) {
  const product = await getProduct(id)
  return <span>{product.price}</span>
}
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.

TYPESCRIPT
// Server Component with Suspense streaming
export default function ProductPage({ params }) {
  return (
    <div>
      <StaticHeader /> {/* Renders immediately, streamed first */}

      <Suspense fallback={<ReviewsSkeleton />}>
        <Reviews productId={params.id} /> {/* Async Server Component — streamed when ready */}
      </Suspense>

      <Suspense fallback={<RecommendationsSkeleton />}>
        <Recommendations productId={params.id} /> {/* Independent stream */}
      </Suspense>
    </div>
  )
}

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.

Pro Tip & Optimization

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:

TYPESCRIPT
'use client'
import { ErrorBoundary } from 'react-error-boundary'

export default function ProductPage({ params }) {
  return (
    <ErrorBoundary fallback={<ErrorMessage message="Failed to load reviews" />}>
      <Suspense fallback={<ReviewsSkeleton />}>
        <Reviews productId={params.id} />
      </Suspense>
    </ErrorBoundary>
  )
}
Performance / Safety Warning

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 typed FormData extraction, useActionState for validation feedback, and the progressive enhancement contract that makes forms work without JavaScript. server-actions-partial-prerendering-rsc →

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#React#RSC#React Flight#Server Components#Next.js#Performance
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.