React Flight Protocol: A Deep Technical Dive
React Flight Protocol: A Deep Technical Dive
Understanding the Wire Protocol Powering React Server Components
Table of Contents
- Introduction
- The Problem Space
- What is React Flight?
- Architecture Overview
- The Wire Format
- Serialization Deep Dive
- Streaming Mechanics
- Client-Side Reconstruction
- Reference Types and Chunks
- Practical Implementation
- Performance Characteristics
- Comparison with Alternatives
- Debugging Flight Payloads
- Conclusion
Introduction
React Flight Protocol is the serialization and streaming protocol that enables React Server Components (RSC) to function. While RSC gets the spotlight, Flight is the unsung hero—a carefully designed wire protocol that solves the non-trivial problem of streaming a component tree from server to client while maintaining React's component model semantics.
This article dissects Flight at the byte level, examining how it serializes React elements, handles references, streams data progressively, and reconstructs the virtual DOM on the client.
The Problem Space
Traditional Server Rendering Limitations
Traditional SSR (Server-Side Rendering) has a fundamental constraint: it produces HTML strings.
┌─────────────────────────────────────────────────────────┐
│ Traditional SSR │
├─────────────────────────────────────────────────────────┤
│ Server Client │
│ ┌─────────┐ ┌─────────┐ │
│ │ React │ ──── HTML ────────► │ Parse │ │
│ │ Render │ String │ HTML │ │
│ └─────────┘ └────┬────┘ │
│ │ │
│ ▼ │
│ ┌─────────┐ │
│ │ Hydrate │ │
│ │ (Re-run │ │
│ │ all JS)│ │
│ └─────────┘ │
└─────────────────────────────────────────────────────────┘
Problems:
- Hydration requires re-executing all component logic on the client
- Bundle size bloat—all components ship to the client
- No streaming of component structure—only HTML chunks
- Loss of component boundaries in the HTML output
- Data fetching logic duplicated between server and client
What We Actually Need
┌─────────────────────────────────────────────────────────┐
│ Ideal Model │
├─────────────────────────────────────────────────────────┤
│ • Stream component tree structure (not just HTML) │
│ • Preserve component boundaries and semantics │
│ • Reference client components without shipping code │
│ • Stream async data as it resolves │
│ • Enable partial hydration / selective interactivity │
└─────────────────────────────────────────────────────────┘
This is precisely what React Flight Protocol provides.
What is React Flight?
React Flight is a streaming serialization protocol designed specifically for React's component model. It consists of two complementary packages:
| Package | Purpose |
|---|---|
react-server-dom-webpack/server | Server-side serialization (Flight Server) |
react-server-dom-webpack/client | Client-side deserialization (Flight Client) |
Note: The
webpacksuffix indicates bundler integration. Alternatives exist for other bundlers (Turbopack, Vite, etc.).
Core Responsibilities
┌──────────────────────────────────────────────────────────────┐
│ Flight Server │
├──────────────────────────────────────────────────────────────┤
│ 1. Serialize React element trees to streamable format │
│ 2. Handle async components (Suspense boundaries) │
│ 3. Resolve Promises and stream results incrementally │
│ 4. Encode references to Client Components │
│ 5. Serialize supported data types (Date, Map, Set, etc.) │
└──────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ Flight Client │
├──────────────────────────────────────────────────────────────┤
│ 1. Parse incoming Flight stream │
│ 2. Reconstruct React element tree │
│ 3. Resolve Client Component references to actual components │
│ 4. Handle streaming updates (Suspense resolution) │
│ 5. Integrate with React's reconciler │
└──────────────────────────────────────────────────────────────┘
Architecture Overview
Request/Response Flow
┌─────────────────────────────────────────────────────────────────────┐
│ Flight Architecture │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Browser Server │
│ ┌─────────┐ ┌─────────────────────────────┐ │
│ │ │ ── RSC Request ───► │ Server Component Tree │ │
│ │ │ │ ┌─────────────────────┐ │ │
│ │ │ │ │ <App> │ │ │
│ │ Flight │ │ │ <Layout> │ │ │
│ │ Client │ │ │ <ServerComp/> │ │ │
│ │ │ │ │ <ClientComp/>───┼──┐ │ │
│ │ │ │ │ </Layout> │ │ │ │
│ │ │ │ │ </App> │ │ │ │
│ │ │ │ └─────────────────────┘ │ │ │
│ │ │ │ │ │ │ │
│ │ │ │ ▼ │ │ │
│ │ │ │ ┌─────────────────────┐ │ │ │
│ │ │ │ │ Flight Server │ │ │ │
│ │ │ │ │ renderToPipe- │ │ │ │
│ │ │ │ │ ableStream() │ │ │ │
│ │ │ │ └──────────┬──────────┘ │ │ │
│ │ │ │ │ │ │ │
│ │ │ ◄── Flight Stream ──┼─────────────┘ │ │ │
│ │ │ (Chunked JSON) │ │ │ │
│ │ │ │ Client Component │ │ │
│ │ │ ◄── JS Bundle ──────┼── Reference Map ◄─────────┘ │ │
│ └─────────┘ └─────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
Component Classification
Flight differentiates between two component types:
// Server Component (default in App Router)
// - Runs ONLY on server
// - Can access databases, filesystems, secrets
// - Serialized to Flight format
// - Never shipped to client bundle
async function ServerComponent() {
const data = await db.query('SELECT * FROM posts');
return <div>{data.map(post => <Post key={post.id} {...post} />)}</div>;
}
// Client Component (marked with 'use client')
// - Runs on client (and server for SSR)
// - Has access to hooks, browser APIs, interactivity
// - Referenced by module ID in Flight payload
// - Shipped in client bundle
'use client';
function ClientComponent({ onClick }) {
const [state, setState] = useState(0);
return <button onClick={onClick}>{state}</button>;
}
The Wire Format
Flight uses a line-delimited JSON streaming format. Each line is a self-contained chunk that can be parsed independently.
Basic Structure
ROW_ID:TYPE_TAG JSON_PAYLOAD\n
| Component | Description |
|---|---|
ROW_ID | Unique identifier for this chunk (hexadecimal) |
TYPE_TAG | Single character indicating payload type |
JSON_PAYLOAD | The actual data (JSON-encoded) |
\n | Newline delimiter |
Type Tags
| Tag | Name | Description |
|---|---|---|
| (none) | Model | React element tree or data model |
I | Module Import | Client Component reference |
H | Hint | Preload hints (fonts, scripts) |
E | Error | Error boundary information |
T | Text | Raw text content |
P | Promise | Pending async value |
S | Symbol | React Symbol (Suspense, Fragment) |
B | Blob | Binary data reference |
Real-World Payload Example
Consider this component tree:
// Server Component
async function Page() {
const user = await getUser();
return (
<main>
<h1>Welcome, {user.name}</h1>
<ClientCounter initial={user.visits} />
</main>
);
}
The Flight payload might look like:
0:I["(app-client)/./src/components/Counter.js",["default"],"ClientCounter"]
1:{"name":"John","visits":42}
2:["$","main",null,{"children":[["$","h1",null,{"children":["Welcome, ","$1.name"]}],["$","$L0",null,{"initial":"$1.visits"}]]}]
Let's decode this:
Line 0: Client Component Import
I["(app-client)/./src/components/Counter.js", ["default"], "ClientCounter"]
- Module path:
./src/components/Counter.js - Export:
default - Display name:
ClientCounter
Line 1: Data Model
{"name": "John", "visits": 42}
- User data fetched on server
Line 2: React Element Tree
["$", "main", null, {"children": [...]}]
$prefix indicates React element$L0references Client Component from line 0$1.namereferences data from line 1
Serialization Deep Dive
Element Encoding
React elements are encoded as tuples:
// React Element
<div className="container" id="main">Hello</div>
// Flight Encoding
["$", "div", null, {"className": "container", "id": "main", "children": "Hello"}]
// │ │ │ └── Props object
// │ │ └── Key (null if none)
// │ └── Element type
// └── Element marker
Special References
Flight uses sigil prefixes for special values:
| Prefix | Meaning | Example |
|---|---|---|
$ | React Element | ["$", "div", ...] |
$L | Lazy (Client Component) | "$L0" → reference line 0 |
$F | Server Reference (Action) | "$F1" → server function |
$D | Date | "$D2024-01-15T..." |
$n | BigInt | "$n12345678901234567890" |
$u | undefined | "$u" |
$-0 | Negative zero | "$-0" |
$-Infinity | Negative infinity | "$-Infinity" |
$NaN | NaN | "$NaN" |
Supported Data Types
// Flight can serialize these types natively:
const supportedTypes = {
// Primitives
string: "hello",
number: 42,
boolean: true,
null: null,
undefined: undefined, // → "$u"
bigint: 123n, // → "$n123"
// Objects
plainObject: { a: 1 },
array: [1, 2, 3],
// Built-in Objects
date: new Date(), // → "$D2024-..."
map: new Map(), // → Special encoding
set: new Set(), // → Special encoding
// Binary
arrayBuffer: new ArrayBuffer(8),
typedArrays: new Uint8Array([1,2,3]),
// React-specific
reactElement: <div />, // → ["$", "div", ...]
promise: Promise.resolve() // → Streamed separately
};
Circular Reference Handling
Flight handles object references to avoid duplication:
const shared = { data: "reused" };
const tree = {
a: shared,
b: shared // Same reference
};
// Flight output uses references:
// 0:{"data":"reused"}
// 1:{"a":"$0","b":"$0"}
// │ └── Reference to line 0
// └── Reference to line 0
Streaming Mechanics
Progressive Rendering
Flight's killer feature is progressive streaming. Async boundaries don't block the entire response.
async function Page() {
return (
<main>
<Header /> {/* Renders immediately */}
<Suspense fallback={<Skeleton />}>
<SlowComponent /> {/* Streams when ready */}
</Suspense>
<Footer /> {/* Renders immediately */}
</main>
);
}
Stream Timeline
Time ─────────────────────────────────────────────────────────►
T=0ms Server starts rendering
┌──────────────────────────────────────┐
│ 0:["$","main",null,{ │
│ "children":[ │
│ ["$","$L1",null,{}], // Header │
│ ["$","$Suspense",null,{ │
│ "fallback":"$3", │
│ "children":"$P4" // Pending │
│ }], │
│ ["$","$L2",null,{}] // Footer │
│ ] │
│ }] │
└──────────────────────────────────────┘
│
▼ (streamed to client)
T=50ms Client renders Header, Skeleton, Footer
T=800ms SlowComponent resolves
┌──────────────────────────────────────┐
│ 4:["$","div",null,{ │
│ "children":"Slow data loaded!" │
│ }] │
└──────────────────────────────────────┘
│
▼ (streamed to client)
T=850ms Client replaces Skeleton with SlowComponent
Server Implementation
import { renderToPipeableStream } from 'react-server-dom-webpack/server';
function handler(req, res) {
const { pipe } = renderToPipeableStream(
<App />,
bundlerConfig,
{
onError(error) {
console.error(error);
},
}
);
res.setHeader('Content-Type', 'text/x-component');
res.setHeader('Transfer-Encoding', 'chunked');
pipe(res);
}
Chunked Transfer Encoding
Flight leverages HTTP chunked transfer encoding:
HTTP/1.1 200 OK
Content-Type: text/x-component
Transfer-Encoding: chunked
2f
0:["$","div",null,{"children":"Hello"}]
2a
1:["$","span",null,{"children":"World"}]
0
Each hex number indicates the chunk byte length, enabling progressive parsing.
Client-Side Reconstruction
Parsing the Stream
import { createFromFetch } from 'react-server-dom-webpack/client';
async function fetchServerComponent(url) {
const response = fetch(url);
// createFromFetch returns a Promise<ReactElement>
// that resolves progressively as chunks arrive
const root = await createFromFetch(response);
return root;
}
// In your React tree:
function App() {
const [tree, setTree] = useState(null);
useEffect(() => {
fetchServerComponent('/rsc/page')
.then(setTree);
}, []);
return tree;
}
Module Resolution
When Flight Client encounters a Client Component reference:
// Flight payload
'0:I["./Counter.js",["default"],"Counter"]'
// Client resolution process:
// 1. Parse module reference
const moduleRef = {
id: "./Counter.js",
exports: ["default"],
name: "Counter"
};
// 2. Look up in webpack manifest
const chunkId = webpackManifest["./Counter.js"]["default"];
// 3. Ensure chunk is loaded
await __webpack_chunk_load__(chunkId);
// 4. Get actual component
const Counter = __webpack_require__(moduleRef.id)[moduleRef.exports[0]];
// 5. Use in tree
<Counter {...props} />
Suspense Integration
Flight integrates deeply with React Suspense:
// Simplified Flight Client internals
class FlightClient {
chunks = new Map();
parseChunk(line) {
const [id, data] = parseLine(line);
if (this.chunks.has(id)) {
// Resolve pending promise
const { resolve } = this.chunks.get(id);
resolve(data);
} else {
// Store for later reference
this.chunks.set(id, { value: data, status: 'resolved' });
}
}
getChunk(id) {
if (!this.chunks.has(id)) {
// Create pending promise for Suspense
let resolve;
const promise = new Promise(r => resolve = r);
this.chunks.set(id, { promise, resolve, status: 'pending' });
throw promise; // Suspense will catch this
}
const chunk = this.chunks.get(id);
if (chunk.status === 'pending') throw chunk.promise;
return chunk.value;
}
}
Reference Types and Chunks
Client Reference
Marks a component that should run on the client:
// Server sees:
import ClientComp from './ClientComp'; // 'use client' module
// Flight serializes as:
{
$$typeof: Symbol.for('react.client.reference'),
$$id: './ClientComp.js#default',
$$async: false
}
// Wire format:
'0:I["./ClientComp.js",["default"],"ClientComp"]'
Server Reference
Marks a function that runs on the server (Server Actions):
// Server:
async function submitForm(formData) {
'use server';
await db.insert(formData);
}
// Flight serializes as:
{
$$typeof: Symbol.for('react.server.reference'),
$$id: 'abc123',
$$bound: null
}
// Wire format:
'0:F["abc123"]'
// Client can call this:
// POST /_rsc?actionId=abc123
Bound Server References
Server Actions can have pre-bound arguments:
// Server:
async function deletePost(postId, formData) {
'use server';
await db.delete('posts', postId);
}
// When passed to client:
<form action={deletePost.bind(null, post.id)}>
// Flight encodes bound arguments:
'0:F["deletePost",["post-123"]]'
// └── Bound argument
Practical Implementation
Custom Flight Server
// flight-server.js
import { renderToPipeableStream } from 'react-server-dom-webpack/server';
import { createServer } from 'http';
import { createElement } from 'react';
// Bundle manifest from webpack
import manifest from './dist/client-manifest.json';
const server = createServer(async (req, res) => {
if (req.url.startsWith('/_rsc')) {
// Dynamic import of server component
const { default: Page } = await import('./app/page.js');
const { pipe } = renderToPipeableStream(
createElement(Page, { url: req.url }),
manifest,
{
onError(err) {
console.error('Flight error:', err);
res.statusCode = 500;
},
}
);
res.setHeader('Content-Type', 'text/x-component');
pipe(res);
}
});
server.listen(3000);
Custom Flight Client
// flight-client.js
import { createFromFetch } from 'react-server-dom-webpack/client';
import { use, Suspense, startTransition } from 'react';
// Cache for navigation
const cache = new Map();
function fetchRSC(url) {
if (!cache.has(url)) {
cache.set(url, createFromFetch(
fetch(`/_rsc${url}`, {
headers: { 'Accept': 'text/x-component' }
})
));
}
return cache.get(url);
}
function ServerRoot({ url }) {
// `use` hook unwraps the promise
const content = use(fetchRSC(url));
return content;
}
function App() {
const [url, setUrl] = useState(location.pathname);
const navigate = (newUrl) => {
startTransition(() => {
setUrl(newUrl);
});
};
return (
<NavigationContext.Provider value={navigate}>
<Suspense fallback={<Loading />}>
<ServerRoot url={url} />
</Suspense>
</NavigationContext.Provider>
);
}
Webpack Configuration
// webpack.config.js
const ReactServerWebpackPlugin = require('react-server-dom-webpack/plugin');
module.exports = {
// Client bundle config
entry: './src/client.js',
plugins: [
new ReactServerWebpackPlugin({
isServer: false,
}),
],
module: {
rules: [
{
test: /\.js$/,
use: {
loader: 'react-server-dom-webpack/loader',
options: { emit: 'client' },
},
},
],
},
};
Performance Characteristics
Payload Size Comparison
┌─────────────────────────────────────────────────────────────┐
│ Payload Size: Flight vs Alternatives │
├─────────────────────────────────────────────────────────────┤
│ │
│ Traditional SSR (HTML + Hydration Data): │
│ ████████████████████████████████████████████ ~45KB │
│ │
│ JSON API + Client Rendering: │
│ ██████████████████████████████ ~30KB (data + JS) │
│ │
│ Flight Protocol: │
│ ████████████████ ~16KB (no duplicate data) │
│ │
└─────────────────────────────────────────────────────────────┘
Time to First Byte (TTFB)
Flight streams immediately without waiting for async operations:
Traditional SSR:
[Wait for all data] ──────────────► [Send HTML]
800ms 50ms
TTFB: 850ms
Flight Streaming:
[Start stream immediately] ──► [Stream chunks as ready]
10ms 840ms (progressive)
TTFB: 10ms
Bundle Impact
// Components NOT in client bundle:
// - ServerComponent.js ✗ Not shipped
// - DatabaseQuery.js ✗ Not shipped
// - ServerUtils.js ✗ Not shipped
// Components IN client bundle:
// - ClientComponent.js ✓ Shipped
// - InteractiveForm.js ✓ Shipped
// Result: Potentially 30-50% smaller client bundles
Comparison with Alternatives
Flight vs GraphQL
| Aspect | Flight | GraphQL |
|---|---|---|
| Schema | Component tree (implicit) | Explicit type definitions |
| Query Language | N/A (server determines) | GraphQL queries |
| Streaming | Native, per-component | @stream/@defer extensions |
| Client Code | Automatic (React) | Manual resolver mapping |
| Caching | Response-level | Field-level normalization |
| Use Case | React UI rendering | General data fetching |
Flight vs tRPC
| Aspect | Flight | tRPC |
|---|---|---|
| Protocol | Custom streaming | JSON-RPC over HTTP |
| Type Safety | Via JSX types | End-to-end TypeScript |
| Output | React elements | Raw data |
| Framework | React only | Framework agnostic |
| Streaming | Built-in | Via SSE or WebSocket |
Flight vs Remix/Qwik
| Aspect | Flight | Remix | Qwik |
|---|---|---|---|
| Streaming | Component-level | Route-level | Component-level |
| Resumability | No (needs JS) | No | Yes |
| Bundle | Partial | Full route | Lazy per-interaction |
| Learning Curve | High | Medium | High |
Debugging Flight Payloads
Chrome DevTools
// In Network tab, look for requests with:
// - Content-Type: text/x-component
// - Look at Response tab for raw Flight payload
// Pretty-print Flight payload:
function parseFlightPayload(text) {
return text.split('\n')
.filter(Boolean)
.map(line => {
const colonIndex = line.indexOf(':');
const id = line.slice(0, colonIndex);
const payload = line.slice(colonIndex + 1);
return {
id,
type: getTypeFromPayload(payload),
data: JSON.parse(payload.replace(/^\w/, ''))
};
});
}
Logging Middleware
// Debug middleware for Flight
function flightDebugMiddleware(req, res, next) {
const originalWrite = res.write;
res.write = function(chunk) {
console.log('[Flight Chunk]', chunk.toString());
return originalWrite.apply(this, arguments);
};
next();
}
Common Issues
// Issue: "Objects are not valid as a React child"
// Cause: Trying to serialize non-serializable value
// Fix: Ensure all props are serializable
// Issue: "Could not find the module"
// Cause: Client component not in manifest
// Fix: Rebuild with proper webpack config
// Issue: Infinite loading
// Cause: Circular Promise references
// Fix: Check for circular async dependencies
Conclusion
React Flight Protocol represents a paradigm shift in how we think about server-client communication in React applications. By serializing the component tree itself rather than raw data, Flight enables:
- True Streaming — Components render as their data becomes available
- Zero-Bundle Server Components — Server code never ships to client
- Automatic Code Splitting — Client components load on demand
- Seamless Data Colocation — Fetch where you render
- Type-Safe Boundaries — Props are validated at serialization
Key Takeaways
┌──────────────────────────────────────────────────────────────┐
│ Flight Protocol Mental Model │
├──────────────────────────────────────────────────────────────┤
│ │
│ 1. It's a SERIALIZATION format, not a transport protocol │
│ │
│ 2. It preserves COMPONENT SEMANTICS, not just data │
│ │
│ 3. It STREAMS progressively, chunks are independent │
│ │
│ 4. Client Components are REFERENCES, not inline code │
│ │
│ 5. It integrates with SUSPENSE for async boundaries │
│ │
└──────────────────────────────────────────────────────────────┘
Flight isn't just an implementation detail—it's the architectural foundation that makes React Server Components possible. Understanding it deeply enables better debugging, performance optimization, and appreciation for the engineering that powers modern React applications.
Further Reading
- React RFC: Server Components
- React Source: ReactFlightServer.js
- React Source: ReactFlightClient.js
- Next.js App Router Documentation
- Vercel: How React Server Components Work
Published: 2024 | Last Updated: 2024 Author: Technical Deep Dive Series
What did you think?