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 defaultnonononoGET/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 3222 (batching is per-tick)1 (server function)21: { "$ref": "1.id" } pipelining
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 draftstreamsnonoSSEevery response is frames; @lazy/@defer deliver later; streams with fin
IdempotencyIdempotency-Key conventionnonenonenonemutations are transactionsnonemandatory key on commands, 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 this table compares, and what it does not. Each column says what the protocol gives you by default, not the ceiling of what a team can build on it. 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. The claim here is not that GraphQL cannot do these things. It is that each is a separate decision, library and convention per team, where 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. Where a row says "no", it means the protocol has no answer of its own, not that the ecosystem has none.

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

Same data, same three flows, careful implementations of each style (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.

FlowImplementationround tripsbytes downbytes upp50 msp99 ms
A. product page (book + author + 3 reviews)REST240500.441.71
GraphQL12821570.481.97
Rayfold (JSON)13021820.411.03
Rayfold (RB)11771430.431.88
B. catalogue list (20 books with author names)REST2281000.834.34
GraphQL12074890.483.29
Rayfold (JSON)120831420.380.92
Rayfold (RB)11080950.441.05
C. place order, read it back with stockREST3327350.473.44
GraphQL21572040.723.80
Rayfold (JSON)12202790.401.01
Rayfold (RB)11011720.420.82

What the numbers say, honestly:

  • Round trips are where Rayfold wins structurally: 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.
  • RB is the smallest in every flow: 62% 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, which 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 differences at this scale are mostly noise. Rayfold's median was the lowest in every flow, which shows only that its executor is not slower than graphql-js or a hand-written REST handler.

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: CDN hit rates for GET/QUERY reads (needs a real cache in front), live-query update latency versus polling, and the Kotlin runtime.

Released under the Apache-2.0 license.