Skip to content

The binary format ​

RB, short for Rayfold Binary, is a second encoding of the frames you have seen as JSON. The same request gives the same frames in either encoding, and a client picks JSON or RB for each request.

What RB is ​

  • Keys from the schema. Both sides number the protocol's own keys (id, data, fin, $type and the rest), then every field, argument, enum value and operation name in the schema, sorted. A key like title goes on the wire as a small number.
  • Strings written once. Each message keeps a string table. The second time "Book" or an author's name appears, RB writes a reference to the first.
  • Short numbers. Integers take as few bytes as they need; 0 to 127 take one.
  • Length-prefixed frames. Each frame starts with its length, so a streamed response or a live query decodes frame by frame as the bytes arrive.

The byte layout is in spec 09.

When to use it ​

Use RB when payload size matters: phones on slow networks, long lists, a steady flow of live updates. Keep JSON where people read the traffic, such as curl, the explorer and logs, and for callers without a copy of the schema, such as scripts and AI agents. One server answers both at the same time, so you can move one client over and compare.

How client and server agree ​

Over HTTP, two headers decide, independently of each other:

HeaderValueMeaning
Content-Typeapplication/rayfoldthe request body is RB
Content-Typeapplication/rayfold+jsonthe request body is JSON
Acceptapplication/rayfoldanswer with RB frames
Acceptapplication/json, one op that ends in one frameanswer with that frame as a single JSON document, the HTTP status derived from its error code
Acceptanything elseanswer with JSON frames, one per line

A JSON body sent with Accept: application/rayfold gets an RB answer. An Accept that lists RB and a JSON type as well gets JSON. Here is book for b1 with its author, RB in both directions:

http
POST /rayfold
Content-Type: application/rayfold
Accept: application/rayfold
Rayfold-Safe: true

(64 bytes)

HTTP/1.1 200 OK
Content-Type: application/rayfold

(79 bytes)

The same request as JSON is 107 bytes up and 158 bytes down. Decoded, both answers are this frame:

json
{"id":1,"data":{"$type":"Book","title":"A Wizard of Earthsea","stock":3,"author":{"$type":"Author","name":"Ursula K. Le Guin"}},"meta":{"cost":2},"fin":true}

Over WebSocket, text messages are JSON and binary messages are RB, and one socket can carry both.

A server lists rb among its extensions in GET /rayfold/manifest ("extensions":["live","rb"]), next to the schema a client needs to build the key numbers. That schema leaves out how access policies decide. RB does not need that part, so the manifest's schema encodes exactly like the server's own copy.

Turn it on ​

The codec is @rayfold/rb on npm, and dev.rayfold:rayfold-core carries the JVM one. You rarely use it directly, because the client and server negotiate it for you. Depend on it if you are writing a client of your own.

TypeScript ​

Load the manifest, then give it to the transport:

ts
import { RayfoldClient, createFetchTransport } from "@rayfold/client";
import { accessToken } from "./session.ts"; // the signed-in user's token, from your identity provider

const url = "http://localhost:4000/rayfold";
const manifest = await (await fetch(`${url}/manifest`)).json();

const client = new RayfoldClient({
  transport: createFetchTransport({ url, binary: manifest, headers: async () => ({ authorization: `Bearer ${await accessToken()}` }) }),
  schema: manifest.schema,
});

const book = await client.query<Book>("book", { id: "b1" }, { shape: "{ title stock author { name } }" });
  • binary: manifest on the transport sends and reads RB. It takes the whole manifest, schema and schemaHash, so it can check that the server still holds that schema before it trusts the key numbers (see Keep the schema in step).
  • schema on the client asks for compact frames: the server leaves out $type wherever the schema already fixes it, and the client puts it back. It works with JSON too, and combined with RB it saves more.

Queries and commands return the same values as over JSON. A WebSocket transport takes the schema too: createWebSocketTransport({ url: "ws://localhost:4000/rayfold/ws", binary: manifest }). The bookshop example serves HTTP only; attachWebSocket(http, server) from @rayfold/server adds the socket.

Kotlin ​

The Kotlin client sends JSON. Its HttpTransport and WebSocket transports have no RB option yet, so there is nothing to turn on. The Kotlin server in rayfold-core answers RB over HTTP and WebSocket like the TypeScript server does, so a TypeScript or browser client can use RB against it.

Keep the schema in step ​

The key numbers come from the schema. Adding one name moves the number of every name sorted after it, so client and server must hold the same schema. Every response carries the server's schema hash in the Rayfold-Schema header, and the manifest has the same hash as schemaHash. The HTTP transport compares them:

  • It sends JSON until a response shows that the server's hash is the manifest's, then switches to RB. The first request of a new transport always goes as JSON.
  • When a response reports another hash, as after a deploy, the requests after it go as JSON again. Load the manifest again and create a new transport to use RB.
  • An RB answer that arrives with another hash is not decoded, because its key numbers would give fields the wrong names. The request fails with unavailable, which is safe to retry, and the retry goes as JSON.

A bare schema IR works in place of the manifest when it is the server's full schema. The manifest's schema alone does not: it leaves out how policies decide, so it hashes differently, and the transport would stay on JSON.

A socket has no header per answer, so the WebSocket transport names its schema's hash when it connects. The server accepts the manifest's schema on its own here too, since it holds the same names as the full schema. A server holding another schema closes the socket with code 4409 before anything is decoded: the batches sent on it fail with unavailable, and every socket the transport opens after that speaks JSON. Load the manifest again and create a new transport to use RB.

Since 0.2.2

The WebSocket schema check came with 0.2.2. A 0.2.1 transport names no schema when it connects, and a server checks nothing on a socket that names none.

What it saves ​

On the bookshop over loopback, counting the bytes of the HTTP bodies:

RequestDirectionJSONJSON, compactRBRB, compact
book b1 { title stock author { name } }up1071226473
down1581087958
books first 20 { items { id title stock author { name } } total hasMore cursor }up15216799108
down452337187154

Compact requests are a few bytes larger going up, because each op says "compact": true. The bookshop has three books; the gap grows with the result.

The repository's benchmark, npm run bench, runs three flows against a store of 36 books, with compact frames. Bytes down:

FlowGraphQLRayfold, JSONRayfold, RB
Book page: a book, its author and 3 reviews282302177
List: 20 books with author names207420831080
Place an order and read it back with stock157220101

Next ​

Released under the Apache-2.0 license.