Skip to content

Rayfold versus the incumbents ​

Two parts: what each mechanism does (verified in this repo's tests and conformance fixtures), and what the same three flows cost over REST, GraphQL and Rayfold (npm run bench, bench/results/latest.md).

1. Mechanisms ​

CapabilityRESTGraphQLgRPC / ConnecttRPCConvex / ZeroMCPRayfold
Client-shaped responsesno (over/under-fetch)selection setsnonoquery languagenoshapes, plus a default view so a bare call works from curl and agents
Shared HTTP cachingyesGET for queries, persisted queries to keep URLs short; POST by defaultGET for idempotent unary RPCs (Connect); none over gRPC/HTTP-2nononoGET/QUERY with ETag + Cache-Control derived from @cache and policies
Typed contractOpenAPI (optional)SDLProtobufTS onlyTS onlyJSON Schema per toolone .rayfold IR: types, ops, auth, cost, cache, views, deprecation
Nullability defaultn/anullableoptional-ishTSTSJSON Schemanon-null; T? opts in
N+1n/aDataLoader by handn/an/an/an/aloaders are batch by default, one call per level across lists and pages
Round trips for create-then-read2 to 31 when the read is reachable from the mutation payload, 2 when it is not22 (batching is per-tick)1 (server function)21: { "$ref": "1.id" } pipelining, for any read
Errorsstatus codeserrors[] + partial data, HTTP 20016 codesthrownthrownisError16 codes and typed domain errors declared with throws; atomic by default, @partial opt-in
Authorizationmiddlewareper-resolverinterceptorsmiddlewarerules in codeOAuth onlypolicy expressions in the schema, evaluated per op / type / field; default views never leak
Cost / DoS controlrate limitsdepth and complexity limits from librariesnonenonenonenonestatic cost from @cost and page sizes, per-batch budget, trusted-shape allowlist
Cache coherence after writesnonemanual cache updatesnoneinvalidateautomaticnonecommands return patches; every client view updates without refetch
RealtimeSSE/WebSocket by handsubscriptions (separate type)streamssubscriptionslive queriesnone"live": true on any query; the server diffs the result it already served and sends only what changed (changed entity fields, rows added to or removed from a list, fields of a plain object), falling back to a fresh data frame when the change cannot be described
Streaming / incrementalchunked by hand@defer shipped by several servers, still a spec draftstreamshttpBatchStreamLink and async-generator outputs (v11)noSSEevery response is frames; @lazy/@defer deliver later; streams with fin
IdempotencyIdempotency-Key conventionnonenonenonemutations are transactionsnonemandatory key on commands unless one opts out with @idempotent(false), replay with meta.replay
Evolutionversionsdeprecation onlyfield numbersnonenonenoneadditive-only enforced by rayfold check, sunset dates, lockfile ordinals, no versions
Agent accessOpenAPI-to-tools adaptersadaptersnonenonenonenativeany Rayfold server is an MCP server (tools, resources, simulate, typed errors)
Binary wirenonoProtobufnononoRB: schema key dictionary + string table; same frames as JSON
DiscoveryOpenAPIintrospectionreflectionnonenonetools/listGET /rayfold/manifest: IR, hash, extensions, limits

What the table compares. Each column says what the protocol gives you by default. A team can build more on top of any of them. Most of the GraphQL rows can be met with work that many teams already do: persisted queries put reads behind GET so a CDN can cache them, DataLoader solves N+1, complexity plugins bound cost, an Idempotency-Key convention makes retries safe, and cache normalisation in Apollo or Relay keeps views current. In GraphQL each of those is a separate decision, library and convention for each team. Rayfold makes them the default and writes them in the schema, so a second team on the same API gets them without repeating the work. A "no" means the protocol has no answer of its own. A library may still provide one.

2. Measured on the bookstore (loopback, Node 24, 300 iterations, interleaved) ​

Same data, same three flows, each style implemented with its usual optimisation (REST with parallel dependent requests, GraphQL with a DataLoader-style batcher). Rayfold sends what its client sends when it has the schema: compact frames, without $type and meta (the GraphQL queries ask for no __typename either). Each iteration runs every implementation once, rotating which one goes first, so drift on the machine affects all of them alike. REST's bytes up exclude request bodies for GETs, which is why its column reads 0 on the two read flows.

FlowImplementationround tripsbytes downbytes upp50 msp99 ms
A. product page (book + author + 3 reviews)REST240500.512.05
GraphQL12821570.592.49
Rayfold (JSON)13021820.602.10
Rayfold (RB)11771430.622.44
B. catalogue list (20 books with author names)REST2281000.922.33
GraphQL12074890.544.51
Rayfold (JSON)120831420.531.59
Rayfold (RB)11080950.582.12
C. place order, read it back with stockREST3327350.524.47
GraphQL21572040.863.44
Rayfold (JSON)12202790.562.76
Rayfold (RB)11011720.582.32

What the numbers show:

  • Round trips: Rayfold needs one for every flow, including create-then-read. Over a real network each extra round trip costs tens of milliseconds; on loopback it is invisible. GraphQL's second trip in flow C is not a hard limit. A mutation whose payload type reaches the data you want can select it inline. It cannot feed an independent root field from the mutation's result, and it can read unrelated data in the same request only when the payload type exposes Query. $ref pipelining covers both.
  • RB is the smallest in every flow: 63% of GraphQL's bytes on the product page, 52% on the list and 64% on the order flow, with frames identical to the JSON run.
  • Compact JSON is close to GraphQL's JSON, 7% larger on the product page and 0.4% on the list. In the order flow it is larger (220 vs 157 bytes) because the command also returns patches. Those keep every cached view correct without a refetch. A client without the schema asks for full frames, which add $type to every entity and meta.cost to every frame.
  • Latency at this scale is noise. Every median here sits between 0.5 and 0.95 ms on loopback. The spread between stacks is smaller than the spread between two runs of the same stack, and the lowest one changes from run to run. These numbers show only that Rayfold's executor is in the same class as graphql-js and a hand-written REST handler, not that it is faster. Over a real network, the round-trip column is the one that matters.

3. The large example: an issue tracker ​

The numbers above come from the bookstore, which is small on purpose. examples/workspace-ts is the opposite: a multi-tenant issue tracker with 2 organisations, 4 teams, 7 projects, 18 sprints, 630 issues (with sub-issues, labels, estimates and versions), 900 comments, 1156 activity entries of four different kinds, and notifications. It uses an interface for the feed, a union for search, row and field policies, per-parent pagination, conditional writes, bulk commands, live queries, @lazy and @partial fields, @cost budgets, @cache scopes, @http bindings, a stream and the MCP bridge.

e2e/workspace.test.ts builds that domain three ways (REST, GraphQL with and without DataLoader, Rayfold) and runs 17 scenarios against all three over real HTTP, checking they give the same answers before measuring what they cost. Results: e2e/workspace.md and the "Issue tracker" section of the HTML report. Rayfold was ahead on 10, level on 7, behind on none.

Some of what it measured:

  • One board (six columns, their totals and the first ten issues of each, with the assignee and labels): 57 486 bytes over REST, 21 783 over GraphQL, 9 446 over Rayfold's binary wire, from one request on each.
  • One issue page (project, people, labels, sub-issues, comments): five round trips over REST, one on the other two.
  • Closing a sprint: the command answers with 11 entity patches, so every cached screen holding those issues is corrected without a refetch. The other two report the new sprint state and leave the client to refetch.
  • One tenant rule: @allow on the entity is enforced on the batch endpoint, the @http route, the MCP resource and the live query alike; REST and GraphQL enforce theirs in the one handler each was written in.
  • N+1: the same GraphQL query and schema without DataLoader made many times more data-source calls than with it; Rayfold has no such mode, because a field loader is handed the whole level.

Not measured yet: hit rates on a commercial CDN under real traffic (CI does put nginx in front as a shared cache and asserts miss-then-hit on public reads and never-hit on private ones), live-query update latency versus polling, and the Kotlin runtime.

Released under the Apache-2.0 license.