Skip to content

docs / core concepts

Flight Wire Format

The Flight wire format ruact speaks between Rails and React — what is on the wire and how to read it.

React Server Components use a streaming protocol called Flight to transfer the component tree from server to browser. ruact writes it from Ruby, and the browser reads it with React’s own Flight client (react-server-dom-webpack, which ships inside the gem). You do not need to understand Flight to use ruact — but knowing the basics helps when debugging unexpected output or adding support for new Ruby types.


The browser decodes the page with React’s own Flight client, the client.browser build of react-server-dom-webpack. ruact does not ship a decoder of its own: it writes the subset of Flight that React’s client reads, and CI decodes payloads produced by the Ruby serializer with that client, in its development and production builds.

  • The copy in use. Version 19.3.0 ships inside the gem, so nothing is added to your package.json. The npm package would also install webpack; the vendored copy avoids that.

  • Supported React: ^19.2.8. CI runs against 19.2.8 and 19.3.0.

  • Using your own copy. Add react-server-dom-webpack to your app’s package.json and the Vite plugin uses it instead of the vendored one. A copy that is only installed as another package’s dependency is not used. Keep it on the same React minor as your react.

  • Which one is running. Vite’s startup and build output print it, and so does rails ruact:doctor:

    Flight client: react-server-dom-webpack 19.3.0 vendored in ruact

    With your own copy, the doctor names its version and warns when it is built for another React minor than your react.

The server and the browser runtime change together: a page served by one ruact version needs that version’s runtime. After a deploy that changes the wire, a tab still open on the old runtime fails on its next in-place navigation and has to reload. The changelog says when a release changes the wire.


Flight is a text format made of rows, each encoding one piece of the React element tree. Most rows are one line:

1:I["/LikeButton.jsx",[],"LikeButton"]
0:["$","$L1",null,{"postId":42}]
  • Row 0 is always the root — the top-level React element tree for the page.
  • Row 1, 2, … are supporting data: import registrations, async chunks, or referenced sub-trees.

Prefix Meaning Example
(none, just JSON) Model row — a JSON value 0:{"title":"Hello"}
I Import row — registers a client module: [id, chunks, name] 1:I["/Btn.jsx",[],"Button"]
E Error row — encodes a serialized error 2:E{"digest":"","name":"Error","message":"Suspense timeout exceeded","stack":[],"env":"Server"}
T Text row — a string of 1024 bytes or more, framed by its byte length, with no newline after it 3:T4b0,<1200 bytes of text>

A model row points at a text row as "$3"; the browser puts the text back in its place. Because a text row can contain newlines and is not followed by one, a Flight payload cannot be read by splitting it into lines.

Row IDs are hexadecimal: 0, 1, 2, …, 9, a, b, …, f, 10, 11, …


Ruby value Flight output
nil 0:null
true / false 0:true / 0:false
42 0:42
2**53 (beyond ±(2^53 − 1)) 0:"$n9007199254740992" → a BigInt in JavaScript
Time, DateTime, ActiveSupport::TimeWithZone 0:"$D2026-09-08T12:30:45.123Z" → a Date in JavaScript
Date (a calendar day) 0:"2026-09-08" → a string
"hello" 0:"hello"
"$danger" 0:"$$danger" ← $ prefix escaped to $$
a string of 1024+ bytes a T row, referenced as "$<id>"
[1, "a", true] 0:[1,"a",true]
{ debug: true } 0:{"debug":true}
ClientReference Import row + model row referencing $L{id}
<Suspense> in ERB a "$Sreact.suspense" symbol row as the element type; the deferred content is a lazy child, "children":"$L<id>"

When your ERB includes <LikeButton postId={@post.id} />, ruact emits:

1:I["/LikeButton.jsx",[],"LikeButton"]
0:["$","$L1",null,{"postId":42}]

Breaking it down:

  • Row 1 (import): registers /LikeButton.jsx with export name "LikeButton". The chunk list is empty: ruact’s runtime registers every client component when the page boots, so there is nothing to load

  • Row 0 (root): a React element — ["$", type, key, props]:

    • "$" — the React element marker
    • "$L1" — the type: a lazy reference to import row 1 (the LikeButton module)
    • null — the element key
    • {"postId":42} — the props

    In development, elements carry three more slots (null,null,1): React’s development build reads the last one as “these children were checked”, so it does not warn about keys on ERB siblings and loops.


The browser processes rows as they arrive. A model row that references "$L1" cannot be rendered until import row 1 is registered. ruact always emits import rows before the model rows that reference them.


The simplest way to see the raw Flight output for any page:

Terminal window
curl -H "Accept: text/x-component" http://localhost:3000/posts/1

The text/x-component Accept header tells ruact to return only the Flight payload (no HTML shell). This is what the browser sends during client-side navigation.


If you need ruact to serialize a custom Ruby type, follow the fixture-first workflow described in CONTRIBUTING.md:

  1. Write a .txt fixture with the expected wire output
  2. Write a failing spec using match_flight_fixture
  3. Add a case/when branch in gem/lib/ruact/flight/serializer.rb
  4. Verify the spec passes with no regressions