Flight Protocol Syntax
Flight Protocol, Wire format or RSC payload are different names for a serialization method that can be used to transfer different types of data between server and client. These data can be react elements, JSON objects, JavaScript primitives, react server function calls or results, ...etc. Flight Protocol format consists of multiple lines separated by new line, each line is called a chunk. Each chunk have the following format
<chunk id (hexa-decimal integer)>:<optional tag><JSON stringified payload>
For example the following JSON object
{ "name": "Alice", "age": 20 }
is serialized into
0:{"name":"Alice","age": 20}
References in Flight Protocol
Flight Protocol can reference chunks in other chunks. The $ sign is used to reference other chunks, for example the following JSON array
[
{ "name": "Alice", "age": 22 },
{ "name": "Pop", "age": 23 },
{ "name": "Alice", "age": 22 },
{ "name": "John", "age": 25 }
]
can be serialized into
0:["$1",{"name":"Pop","age":23},"$1","$2"]
1:{"name":"Alice","age":22}
2:{"name":"John","age":25}
Order of chunks is not mandatory, the following serialization is valid as well
2:{"name":"Alice","age":22}
0:["$2",{"name":"Pop","age":23},"$2","$1"]
1:{"name":"John","age":25}
React usually doesn't do references at JSON objects to share common info at them to DRY it, however, if you did references in serialized data fed to React deserializer, it will understand it well.
JavaScript Primitives
It serialize different JavaScript primitives and objects like strings, numbers, BigInt, Dates, symbols, Maps, Sets, Uint8Array and Float64Array. Primitives that are supported in JSON such as strings and numbers are serialized in the same way that JSON use to serialize them. The serialization of the following JavaScript object shows how all of these primitives are serialized:
{
null: null,
undefined: undefined,
number: 42,
boolean: true,
string: 'hello world',
specialNumbers: {
inf: Infinity,
negInf: -Infinity,
notANumber: NaN,
negativeZero: -0,
},
date: new Date('2025-01-15T10:30:00Z'),
globalSymbol: Symbol.for('my.test.symbol'),
map: new Map([['a', 1], ['b', 2]]),
set: new Set([10, 20, 30, 'hello']),
Uint8Array: new Uint8Array([72, 101, 108, 108, 111]),
Float64Array: new Float64Array([3.14, 2.718]),
dollarString: '$100 dollars',
}
is serialized into
1:[["a",1],["b",2]]
2:[10,20,30,"hello"]
3:o5,Hello
4:g10,<16 bytes of binary float64 data>
0:{"null":null,"undefined":"$undefined","number":42,"boolean":true,"string":"hello world","specialNumbers":{"inf":"$Infinity","negInf":"$-Infinity","notANumber":"$NaN","negativeZero":"$-0"},"date":"$D2025-01-15T10:30:00.000Z","globalSymbol":"$Smy.test.symbol","map":"$Q1","set":"$W2","Uint8Array":"$3","Float64Array":"$4","dollarString":"$$100 dollars"}
As you can notice, types that JSON can't normally represent are encoded using a $ prefix followed by a letter that indicates the type. $undefined for undefined, $Infinity for Infinity, $-Infinity for negative infinity, $NaN for NaN and $-0 for negative zero. Dates are encoded as $D followed by the ISO string like $D2025-01-15T10:30:00.000Z. BigInt is encoded as $n followed by the digits like $n99999999999999999. Symbols created with Symbol.for() are encoded as $S followed by the name like $Smy.test.symbol.
If the actual string value start with $, it gets escaped with an extra $. So the string $100 dollars becomes $$100 dollars at the wire. The client strip the extra $ when deserializing.
Maps and Sets are outlined to their own chunks. The Map data is serialized as array of key-value pairs like [["a",1],["b",2]] and referenced from the parent using $Q<chunk id>. Sets are similar but serialized as array of values like [10,20,30,"hello"] and referenced with $W<chunk id>.
Typed arrays like Uint8Array and Float64Array are also outlined to their own chunks but they use binary row format instead of JSON. Binary rows have different format:
<chunk id>:<tag><length in hex>,<raw binary data>
For example 3:o5,Hello mean chunk id 3, tag o (Uint8Array), length 5 bytes in hex, then the raw bytes. The bytes 72, 101, 108, 108, 111 are the ASCII codes for "Hello" that's why it appear readable at the output. Float64Array use tag g and the binary data is the raw IEEE 754 representation which is not human readable.
Each typed array type have its own tag: A for ArrayBuffer, O for Int8Array, o for Uint8Array, U for Uint8ClampedArray, S for Int16Array, s for Uint16Array, L for Int32Array, l for Uint32Array, G for Float32Array, g for Float64Array, M for BigInt64Array, m for BigUint64Array and V for DataView.
Only symbols created with Symbol.for() can be serialized. Local symbols created with Symbol() will throw an error.
Long strings (roughly over 1KB) also switch to binary format using tag T instead of being encoded as JSON string.
React Elements
React elements are serialized as JSON arrays in the following format:
["$", type, key, props]
"$" at the first position represent REACT_ELEMENT_TYPE (the $$typeof of the element). type is the element type, it can be a string like "div" or a reference to a client component like "$L1". key is the React key or null. props is the props object.
For example the following JSX:
<div className="app">
<h1>Title</h1>
<p>Body</p>
</div>
is serialized into
0:["$","div",null,{"className":"app","children":[["$","h1",null,{"children":"Title"}],["$","p",null,{"children":"Body"}]]}]
Elements are nested inside each other, the children prop contains the child elements as nested arrays.
Server Components vs Client Components
Server components (functions without "use client") are executed at the server and their return value is what get serialized. The server component function itself never appear at the output, only what it return.
Client components (marked with "use client") are NOT executed at the server. Instead flight serialize a reference to the client module so the browser can load and execute it. This produce a new type of chunk called Import chunk which has the tag I.
For example if you have:
// Counter.js
'use client';
export function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount((c) => c + 1)}>{count}</button>;
}
// Page.js (server component)
import { Counter } from './Counter';
export function Page() {
return (
<div>
<h1>My Page</h1>
<Counter />
</div>
);
}
Serializing <Page /> produce:
1:I{"id":"./src/Counter.js","chunks":["chunk-abc"],"name":"Counter"}
0:["$","div",null,{"children":[["$","h1",null,{"children":"My Page"}],["$","$L1",null,{}]]}]
Two chunks are produced. Chunk 1 is an Import chunk (tag I) that contains the module metadata: the module id, what webpack chunks to load, and the export name. Chunk 0 is the element tree where the Counter element type is "$L1" which is a lazy reference to chunk 1.
The $L prefix is important. It tells the client to wrap it in React.lazy() so react can show a Suspense fallback while the module is loading. If you pass the client component as a prop value (not as element type) it use $ instead of $L:
// as element type -> "$L1" (lazy)
React.createElement(Counter);
// => ["$","$L1",null,{}]
// as prop value -> "$1" (direct reference)
{
myComponent: Counter;
}
// => {"myComponent":"$1"}
Promises and Streaming
When a server component is async, Flight handles it with promise references using the $@ prefix. This is what enables streaming in React Server Components.
In React on Rails, slow data is streamed using async props — Rails emits each prop as it resolves, and the component awaits the promise via getReactOnRailsAsyncProp. React on Rails injects getReactOnRailsAsyncProp into the component props when you use stream_react_component_with_async_props. Here's an example:
sleep blocks a Puma thread and is for demo purposes only. Replace it with your actual slow query; do not use sleep in a real application.
<%# Rails view: stream slow data as an async prop %>
<%= stream_react_component_with_async_props("Page",
props: { title: "Fast Header" }) do |emit|
sleep 2 # Demo only: blocks the Puma thread; replace with your actual slow query
emit.call("slowData", { message: "Loaded after 2 seconds" })
end %>
// React component: await the async prop
import { Suspense } from 'react';
function Page({ title, getReactOnRailsAsyncProp }) {
const slowDataPromise = getReactOnRailsAsyncProp('slowData');
return (
<div>
<h1>{title}</h1>
<Suspense fallback={<p>Loading...</p>}>
<SlowData dataPromise={slowDataPromise} />
</Suspense>
</div>
);
}
async function SlowData({ dataPromise }) {
const data = await dataPromise; // Resolves when Rails emits it
return <p>{data.message}</p>;
}
React on Rails note:
getReactOnRailsAsyncProp('slowData')returns a Promise that resolves when Rails callsemit.call("slowData", ...). The component awaits this promise inside a<Suspense>boundary, so React streams the fallback immediately and swaps in the real content when the data arrives. See RSC Data Fetching Patterns for the full pattern.
When Rails calls emit.call("slowData", ...), the server sends a second Flight chunk. Until then the client already received and rendered the first chunk (the div, h1, and the Suspense fallback):
0:["$","div",null,{"children":[["$","h1",null,{"children":"Fast Header"}],["$","$Sreact.suspense",null,{"fallback":["$","p",null,{"children":"Loading..."}],"children":"$L1"}]]}]
At this point chunk 1 is not resolved yet. The client renders the div and h1 immediately and shows the Suspense fallback. When Rails emits the async prop, the server sends:
1:["$","p",null,{"children":"Loaded after 2 seconds"}]
Now chunk 1 is resolved and the $L1 lazy reference is complete. React replace the fallback with the actual content. This is how streaming works, you don't need the whole tree to be ready before sending the first byte to the client.
For plain promises (not elements), $@ is used:
0:{"fast":"hello","slow":"$@1"}
1:"resolved after 2 seconds"
The root object is available immediately with the fast property, but slow is a promise reference that resolve when chunk 1 arrive.
Error Chunks
When an error happen during rendering, the server send an error chunk with the E tag:
0:E{"digest":"NOT_FOUND","message":"page not found"}
In development mode the error chunk contains more information like the error name, stack trace and environment:
0:E{"digest":"NOT_FOUND","name":"NotFoundError","message":"page not found","stack":[],"env":"server"}
Hint Chunks
Hint chunks are special because they don't have a chunk id. They are used to tell the client to preload resources like stylesheets or fonts. The format is :H<code><JSON data>.
For example:
:HD["https://cdn.example.com/style.css","style"]
The D after H is the hint code that indicates what type of resource to preload. Hints are emitted before any other chunks so the browser can start downloading resources as early as possible.
Stream and Async Iterable Chunks
Flight Protocol also support serializing ReadableStream and AsyncIterable objects. These use special tags to control the lifecycle: R to start a readable stream, r to start a readable byte stream, X to start an async iterable, x to start a byte async iterable and C to close/end the stream.
Chunk Emission Order
The server doesn't send chunks at random order. It queue them into priority buckets and flush at this order:
- Hint chunks (
:H...) so the browser start fetching resources immediately - Import chunks (
Itag) so client JavaScript can start loading - Regular model chunks which is the actual data
- Error chunks
This is intentional. By the time the client start parsing the model data, the resources and modules referenced in it are already being downloaded.
Row Format Summary
Text rows are terminated by newline:
<hex id>:<tag><JSON>\n
Binary rows are terminated by byte count:
<hex id>:<tag><hex length>,<raw bytes>
The client parser know which format to use based on the tag character. Binary tags are T, A, o, O, S, s, L, l, G, g, M, m, V, U and b. All other tags and untagged rows are text format terminated by newline.
If the byte after : is not a recognized tag letter (like { or " or a digit), the parser treat it as untagged model row and start reading JSON from that byte. Thats why 0:{"name":"x"} works without any tag, because { is not a recognized tag character.