One request per screen
Send several operations together, and let a later one use an earlier one's result. Placing an order and showing it is one round trip.
Batches
An API protocol for queries, commands and live data. Commands tell clients what they changed, so nothing refetches. The schema says who may do what and what a request may cost, and the server enforces it. For TypeScript, React, Angular, Kotlin, Java and Spring Boot.
npm install @rayfold/server @rayfold/client.rayfold schema: types, operations and their rulescreateRayfoldServer and call it with RayfoldClientThe quickstart, step by step On the JVM: Kotlin, Java, Spring Boot
| Task | REST | GraphQL | Rayfold |
|---|---|---|---|
| Round trips to order and pay | 2 | 2 | 1 |
| Refetches to keep a list right after a write | 1 | 1 | 0 |
| Duplicate orders when a retry has no key | 1 | 1 | 0 |
| Author lookups for 20 books, written plainly | 9 | 20 | 1 |
| Pieces you write for live stock | 3 | 4 | 1 |
| Extra parts to serve an AI agent | 2 | 2 | 0 |
Send several operations together, and let a later one use an earlier one's result. Placing an order and showing it is one round trip.
Batches
Every command returns patches for what it changed, and the client cache applies them. Add live true to a query and other people's changes arrive too.
Live updates
Who may read or change what, what a request may cost and how long a result may be cached are declared once and enforced on every request.
Who can do what
A loader gets every parent at once, so a page of 50 books loads its authors in one call, not 50.
Queries and shapes
Commands declare what can go wrong. Clients handle OutOfStock by name, with its data, instead of parsing a message.
Commands and errors
JSON over POST, cacheable GET, REST routes bound in the schema, a generated OpenAPI document, and an MCP endpoint so AI agents can use the same API.
REST routes and OpenAPI
A schema, a request for one screen, and what comes back. The same request works against a TypeScript, Kotlin, Java or Spring Boot server.
The schema
entity Book {
id: ID
title: String
stock: Int
author: Author
}
query book(id: ID): Book?
command buy(bookId: ID, qty: Int = 1): Book
throws OutOfStock
@allow(write: viewer != null)One request, two steps
{
"rayfold": "0.1",
"ops": [
{ "id": 1, "op": "buy",
"args": { "bookId": "b1" },
"key": "7f3c9a2e4b1d4c6f" },
{ "id": 2, "op": "book",
"args": { "id": { "$ref": "1.id" } },
"shape": "{ title stock author { name } }",
"live": true }
]
}Frames back
{ "id": 1,
"ok": { "$type": "Book", "id": "b1",
"title": "A Wizard of Earthsea", "stock": 2 },
"patch": [{ "set": "Book:b1", "value": {
"$type": "Book", "id": "b1",
"title": "A Wizard of Earthsea", "stock": 2 } }],
"meta": { "cost": 1 }, "fin": true }
{ "id": 2,
"data": { "$type": "Book",
"title": "A Wizard of Earthsea", "stock": 2,
"author": { "$type": "Author",
"name": "Ursula K. Le Guin" } },
"meta": { "cost": 2 } }
{ "id": 2,
"patch": [{ "at": "", "value": { "stock": 7 } }] }The last frame comes later, when a member of staff restocks the book. The query was sent with live: true, so the server keeps it open and sends only what changed.
The server says what each write changed. The code that keeps screens correct after a write is no longer needed. Here is one feature written the usual way and then in Rayfold: buy a book, show it, and leave every other open screen correct.
Before. Nothing here is wrong or unusual. This is typical client code today.
// 1. the write
await client.mutate({ mutation: BUY, variables: { bookId: "b3", qty: 2 } });
// 2. the read it implies: a second round trip, because the mutation's result
// does not carry the fields this screen shows
const { data } = await client.query({
query: BOOK,
variables: { id: "b3" },
fetchPolicy: "network-only",
});
// 3. every other open view holding that book is now stale. Either refetch them,
// or write a cache update for each, and keep that list correct as screens are added
await client.refetchQueries({ include: [CATALOGUE, CART_BADGE, STOCK_BANNER] });After.
const batch = client.batch();
const bought = batch.command<Book>("buy", { bookId: "b3", qty: 2 }, { shape: "{ id }" });
const book = batch.query<Book>("book", { id: bought.ref("id") }, { shape: "{ title stock }" });
await batch.run();
const { title, stock } = await book.promise;
// step 3 is not shortened, it is gone: `buy` answered with patches for what it
// changed, and every cached view holding that book has already applied themTwo round trips become one. Step 2 travels in the same request and takes its argument from step 1's result. Step 3 is gone, because the command's answer carries the patch. Add live: true to the query and another customer's purchase arrives as a patch too, with no second system to run.
What you give up: every operation has to be declared in a schema first, and your team learns a new protocol. Should you use Rayfold? says when that is worth it and when it is not. The comparison shows where REST and GraphQL reach the same result with more work.
Every guide builds the same small bookshop. Every example project runs its tests on each change to the repository, so the code you copy works.