React Server Components Flight Protocol: Streaming Wire Format, Serialization & Selective Hydration

Table of Contents(15 sections)
React Server Components (RSC) fundamentally alter the mental model of React application development, shifting rendering and data fetching predominantly to the server. This paradigm introduces a new communication protocol, React Flight, which dictates how the server transmits UI updates to the client. Understanding this protocol, its serialization mechanisms, and the subsequent selective hydration process is critical for optimizing RSC-based applications.
The React Flight Protocol: A Streaming Wire Format
The React Flight protocol is a custom, streaming JSON-like format designed for efficient transmission of React element trees. Unlike traditional HTML, which is a static document, Flight is a dynamic, instruction-based stream. It's not raw HTML; it's a serialized representation of React elements, including their types, props, and children, along with instructions for the client to reconstruct the UI.
The core principle is that the server renders components and sends a stream of "chunks" to the client. Each chunk can contain new UI elements, updates to existing ones, or instructions to load client-side code. This streaming nature is crucial for perceived performance, allowing the client to start rendering parts of the UI before the entire component tree is resolved.
Wire Format Structure
The Flight wire format is a sequence of JSON arrays, each representing a specific instruction or data payload. The first element of each array is a numeric tag indicating the type of instruction.
Consider a simple RSC:
// app/page.tsx (Server Component)
import ClientComponent from './ClientComponent';
export default function Page() {
const data = fetchData(); // Server-side data fetching
return (
<div>
<h1>Welcome to RSC</h1>
<p>{data}</p>
<ClientComponent />
</div>
);
}
// app/ClientComponent.tsx (Client Component)
'use client';
import { useState } from 'react';
export default function ClientComponent() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
Client Count: {count}
</button>
);
}
When Page is rendered on the server, the Flight protocol might emit a stream resembling this (simplified for clarity):
0:["$","div",null,{"children":[["$","h1",null,{"children":"Welcome to RSC"}],["$","p",null,{"children":"Server Data"}],["$","@1",null,{}]]}]
1:I{"id":"./app/ClientComponent.tsx","chunks":["app_ClientComponent_tsx"],"name":"default"}
Let's break down the common instruction types:
0: [type, key, ref, props]- Represents a React element.
type: Can be a string (for intrinsic elements like"div","p") or a reference to a client component module (e.g.,"$@1").key: Reactkeyprop.ref: Reactrefprop.props: An object containing the element's props.
1: I{id, chunks, name}- Instruction to import a client component module.
id: Module identifier (e.g., path to the client component file).chunks: Array of chunk names to load for this module.name: Exported name from the module (e.g.,"default").
2: [id, props]- Represents a client component instance.
idrefers to a previously imported module.
- Represents a client component instance.
3: [id, value]- Represents a serialized value (e.g., a promise that resolves to a component).
4: [id, error]- Represents an error boundary fallback.
5: [id, promise_id]- Represents a promise that needs to be resolved. The client will wait for another chunk with
promise_idto resolve this.
- Represents a promise that needs to be resolved. The client will wait for another chunk with
The client-side React runtime parses this stream, reconstructs the virtual DOM, and then renders it. When an I instruction is encountered, the client-side bundler (e.g., Webpack, Turbopack) is instructed to load the corresponding JavaScript chunk for the client component.
Serialization of Props
Not all JavaScript types can be directly serialized over the wire. Functions, event handlers, and complex objects that are not plain data structures cannot be passed directly from a Server Component to a Client Component.
Server Component to Client Component Props: Only serializable data (primitives, plain objects, arrays) can be passed. Functions, JSX elements, or class instances are not serializable.
// ServerComponent.tsx
import ClientComponent from './ClientComponent';
export default function ServerComponent() {
const serverData = { message: 'Hello from server' };
// This is fine: serverData is serializable
return <ClientComponent data={serverData} />;
}
// ClientComponent.tsx
'use client';
export default function ClientComponent({ data }) {
// data.message will be 'Hello from server'
return <p>{data.message}</p>;
}
Client Component to Server Component Props (via children): This is a common pattern. A Server Component can render a Client Component, and pass a Server Component as a child. The Server Component child is rendered on the server and its serialized output is passed to the Client Component.
// ServerComponent.tsx
import ClientWrapper from './ClientWrapper';
export default function ServerComponent() {
// Server-rendered content passed as children to a Client Component
return (
<ClientWrapper>
<h2>This is a Server Component child</h2>
<p>It was rendered on the server.</p>
</ClientWrapper>
);
}
// ClientWrapper.tsx
'use client';
import { useState } from 'react';
export default function ClientWrapper({ children }) {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(count + 1)}>Click me: {count}</button>
{children} {/* Server-rendered content */}
</div>
);
}
In this scenario, children is not serialized as a function or JSX object. Instead, the <h2> and <p> elements are rendered on the server into their Flight wire format representation, and that serialized representation is passed to ClientWrapper. The client-side React runtime then processes this serialized children as part of the ClientWrapper's props.
Client and Server Component Partitioning
The distinction between Client and Server Components is enforced at the build step. Bundlers (like Webpack or Turbopack in Next.js) analyze the 'use client' directive.
- Server Components: Are never bundled for the client. Their code remains exclusively on the server. Any imports within a Server Component are also assumed to be server-side unless they explicitly import a Client Component.
- Client Components: Are bundled for the client. When a Server Component imports a Client Component, the bundler creates a separate JavaScript chunk for that Client Component. The Server Component then only transmits a reference to this chunk via the Flight protocol.
This partitioning is critical for reducing client-side bundle size and enabling server-side data fetching without exposing sensitive logic or credentials to the client.
Streaming HTML Shells and Suspense
RSC leverages React's Suspense mechanism for streaming. When a Server Component performs an asynchronous operation (e.g., data fetching) that hasn't resolved yet, React can suspend rendering that part of the tree.
Instead of waiting for all data to resolve, the server can send an initial HTML "shell" containing the resolved parts of the UI and placeholders (like <template> tags or <div>s with data-rsc-id attributes) for the suspended content.
<!-- Initial HTML Shell -->
<!DOCTYPE html>
<html>
<head>...</head>
<body>
<div id="__next">
<h1>Welcome to RSC</h1>
<!-- Placeholder for suspended content -->
<div id="rsc-123"></div>
</div>
<script src="/_next/static/chunks/main.js"></script>
</body>
</html>
As the suspended data resolves on the server, React sends additional Flight protocol chunks. These chunks contain the actual UI for the suspended parts, which the client-side React runtime then uses to replace the placeholders. This allows for faster Time To First Byte (TTFB) and First Contentful Paint (FCP).
// Later Flight chunk resolving rsc-123
3:["rsc-123",["$","p",null,{"children":"Resolved Server Data"}]]
This mechanism is powered by ReactDOMServer.renderToReadableStream (for Node.js environments) or renderToPipeableStream. These APIs return a stream that first emits the HTML shell, then continues to emit script tags that contain the Flight protocol chunks.
Selective Hydration
Hydration is the process where client-side React takes over the server-rendered HTML, attaching event listeners and making the UI interactive. In traditional React, the entire application tree would be hydrated at once. If a large, non-critical component was slow to hydrate, it would block interactivity for the entire page.
Selective Hydration, introduced with React 18, addresses this by prioritizing hydration. When the client-side React runtime starts, it doesn't immediately hydrate everything. Instead, it observes user interactions.
- Initial Hydration: React starts hydrating from the root, but it can pause if it encounters a
<Suspense>boundary or a component that is still loading. - User Interaction: If a user interacts with a specific part of the DOM (e.g., clicks a button), React prioritizes hydrating the component responsible for that interaction and its ancestors. This means critical interactive elements become responsive much faster.
- Background Hydration: Non-critical components continue to hydrate in the background, without blocking user interactions.
This prioritization is achieved by React internally tracking which parts of the tree are "blocked" (e.g., waiting for a chunk to load or a promise to resolve) and which are "interactive." When an event occurs, React determines the lowest common ancestor of the event target and the component that needs to handle it, then prioritizes hydrating that path.
Example Flow:
- Server sends HTML shell with a button and a large, slow-loading component.
- Client receives HTML. Button is visible but not interactive.
- User clicks the button.
- React detects the click, identifies the button's component, and prioritizes hydrating that component and its parents.
- Button becomes interactive almost immediately.
- Meanwhile, the slow-loading component continues to fetch its data and hydrate in the background, without blocking the button.
Architecture & Tradeoffs
| Feature | Traditional SSR (e.g., Next.js Pages) | React Server Components (RSC) |
|---|---|---|
| Data Fetching | getServerSideProps, getStaticProps, useEffect on client | Direct async/await in Server Components |
| Bundle Size | Client bundles include all component code | Client bundles only include Client Components |
| Hydration | All-or-nothing (React 17), Selective (React 18) | Selective Hydration, enhanced by streaming |
| Network Payload | HTML + JSON data (for props) | HTML shell + Flight Protocol stream |
| Interactivity | After full hydration | Progressive, prioritized by user interaction |
| Complexity | Clear client/server separation | Blended client/server model, new mental model |
| Caching | HTML caching, CDN | HTML caching, RSC payload caching (experimental) |
Production Gotchas & Troubleshooting
-
"Functions are not valid as a React child" / "Objects are not valid as a React child" errors with RSC:
- Problem: Attempting to pass a function, JSX element, or non-serializable object directly from a Server Component to a Client Component as a prop. The Flight protocol cannot serialize these.
- Fix:
- If passing JSX, render it on the server and pass the result (serialized Flight payload) as
childrento the Client Component. - If passing a function, define it within the Client Component itself or pass only serializable data and let the Client Component construct the function.
- Ensure all props passed from Server to Client Components are JSON-serializable.
- If passing JSX, render it on the server and pass the result (serialized Flight payload) as
- Example:
tsx
// ❌ Bad: Passing a function from Server to Client // ServerComponent.tsx function handleClick() { console.log('clicked'); } <ClientButton onClick={handleClick} /> // ✅ Good: Function defined in Client Component // ClientButton.tsx 'use client'; export default function ClientButton() { function handleClick() { console.log('clicked'); } return <button onClick={handleClick}>Click</button>; }
-
Client Component code accidentally running on the server:
- Problem: Forgetting the
'use client'directive at the top of a file. The bundler will treat it as a Server Component, leading to errors if it uses client-only APIs (e.g.,window,useState). - Fix: Always include
'use client'at the very top of any file intended to be a Client Component. - Troubleshooting: Check stack traces for server-side errors originating from files you expected to be client-only.
- Problem: Forgetting the
-
Large Client Component bundles due to transitive dependencies:
- Problem: A small Client Component imports a large library, pulling that entire library into the client bundle.
- Fix:
- Analyze client bundle sizes using tools like Next.js Bundle Analyzer.
- Refactor to move as much logic as possible into Server Components.
- Use dynamic imports (
import()) withReact.lazyfor less critical client components to lazy-load them. - Ensure libraries are tree-shakable.
- Example:
tsx
// ClientComponent.tsx 'use client'; import { SomeHeavyUtility } from 'heavy-library'; // This pulls heavy-library into client bundle // Consider if SomeHeavyUtility can be used in a Server Component // or if this Client Component can be dynamically imported.
-
Slow initial page load despite streaming:
- Problem: The root layout or critical components are suspending for too long, delaying the initial HTML shell.
- Fix:
- Identify the slowest data fetches or components at the root.
- Move non-critical data fetching deeper into the component tree, wrapped in
<Suspense>boundaries, so they don't block the initial shell. - Ensure your server-side data fetching is optimized (e.g., database queries, API calls).
- Use
loading.tsxin Next.js to provide immediate fallback UI.
-
Mismatched HTML during hydration:
- Problem: The server-rendered HTML differs from what the client-side React expects to render, leading to hydration errors (
Warning: Prop 'className' did not match.). This often happens with dynamic content or client-only logic that accidentally runs on the server. - Fix:
- Ensure any client-only logic (e.g.,
windowaccess,localStorage) is guarded bytypeof window !== 'undefined'or placed withinuseEffecthooks. - Avoid using
Math.random()orDate.now()directly in components that are both server and client rendered without careful synchronization. - If using a third-party library, ensure it's compatible with SSR/RSC. Sometimes, wrapping client-only libraries in a component with
suppressHydrationWarning(as a last resort) or dynamic import can help.
- Ensure any client-only logic (e.g.,
- Problem: The server-rendered HTML differs from what the client-side React expects to render, leading to hydration errors (
Frequently Asked Questions
1. Can I use useState or useEffect in a Server Component?
No. Server Components are stateless and do not have access to React Hooks like useState, useEffect, useRef, or useContext. These hooks are client-side constructs for managing state and side effects in the browser. Any component using these must be marked with 'use client'.
2. How do I share data between Server and Client Components?
Data can be passed from a Server Component to a Client Component via props, provided the data is serializable (primitives, plain objects, arrays). To pass data from a Client Component to a Server Component, you typically need to use a server action or an API route. Server actions allow you to invoke server-side functions directly from client components.
3. What happens if a Server Component imports a client-only library (e.g., one that uses window)?
If a Server Component imports a library that relies on browser-specific APIs (like window or document) without proper safeguards, it will cause a server-side runtime error. The bundler will not automatically exclude this code from the server build unless it's part of a Client Component. You must ensure such imports are only made within files marked 'use client', or dynamically import them with ssr: false if using Next.js's next/dynamic.
4. How does the Flight protocol handle errors during streaming?
When an error occurs on the server during the rendering of a Server Component, React can send an error instruction (4: [id, error]) in the Flight stream. This allows the client to display an error boundary fallback for the affected part of the UI, rather than crashing the entire application. This is similar to how <Suspense> boundaries catch promises.
5. Is the Flight protocol specific to Next.js?
No, the React Flight protocol is a core part of React Server Components and is not exclusive to Next.js. Next.js is an early adopter and provides a robust framework for implementing RSC. Other frameworks or custom setups could theoretically implement the Flight protocol to leverage RSC, but Next.js provides the integrated tooling (bundling, routing, data fetching) that makes it practical.
Free In-Browser Developer Tools
Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.
Related Articles

React 19 Actions in Practice: useActionState, useOptimistic & Server Action Resiliency
Comprehensive guide covering react 19 actions in practice: useactionstate, useoptimistic & server action resiliency with production-grade architecture and code examples.
Read more
Next.js 15 Partial Prerendering (PPR): Combining Static Shells with Dynamic Streaming
Comprehensive guide covering next.js 15 partial prerendering (ppr): combining static shells with dynamic streaming with production-grade architecture and code examples.
Read more
React 19 Compiler Deep Dive: Eliminating useMemo, useCallback & Profiler Benchmarks
Comprehensive guide covering react 19 compiler deep dive: eliminating usememo, usecallback & profiler benchmarks with production-grade architecture and code examples.
Read more