Skip to content

About

Apollo Client's normalized cache, with the engine rebuilt in Rust and compiled to WebAssembly.

Topics

Resources

Stars

0 stars

Watchers

1 watching

Forks

Latest commit

 

History

108 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Ferris the crab      Apollo Client      Rust      WebAssembly
fast-gql-cache-rs 🦀

Status: pre-alpha, not released Waitlist Sponsor CI

Apollo Client's normalized cache, with the engine rebuilt in Rust and compiled to WebAssembly.

InMemoryCacheRs is a replacement for Apollo Client's InMemoryCache, built for applications that write a lot: polling dashboards, live feeds, subscriptions, big lists that refresh every few seconds. It moves the cache's work into a Rust engine compiled to WebAssembly, with the goal that every write gives the main thread back sooner.

Same ApolloClient. Same hooks. Same queries. You swap one constructor:

- const cache = new InMemoryCache({ typePolicies });
+ const cache = new InMemoryCacheRs({ typePolicies });

(Policies have to be declarative: key arrays and named merge behaviours instead of custom functions. See the trade.)

Read the design: RFC 0001: The architecture of InMemoryCacheRs

You probably don't need this

Apollo's InMemoryCache is excellent. It is mature, flexible and fast where it counts, and for the vast majority of applications it is the right choice. If your app fetches a page, renders it and moves on, you can close this tab and go ship something. We won't mind.

But you are in the right place if:

  • your workload writes constantly: polling every few seconds, a subscription firehose, thousands of entities refreshed at once;
  • you have seen the profile: writeQuery and broadcastWatches eating frames while scrolling stutters and typing lags;
  • you're fine with declarative cache policies: key arrays and named merge behaviours instead of custom read/merge functions;
  • or you're curious how far Rust and WebAssembly can push a cache that has to stay synchronous.

Want it when it ships? Join the waitlist: one thumbs-up, no sign-up. Want it sooner? Sponsor the work.

Wait, isn't a cache just a Map?

Already know Apollo Client's cache? Skip to where it hurts.

Not this one. If you haven't used Apollo Client: it is one of the most popular GraphQL clients for React, and InMemoryCache is the part that holds your data. Calling it a cache undersells it. It is a reactive, normalized database that runs in the browser:

  • Normalized. A GraphQL response is a tree. The cache flattens it into records keyed by id, such as User:U7. Every query that shows Ada points at the same record, so when her name changes, every screen that shows her updates.
  • Reactive. Each useQuery is a live view. When a write changes a field, the cache knows which results read that field. It re-reads only those results and re-renders only the components whose data actually changed.
  • Memoized. Reading an unchanged query takes microseconds and returns the same objects (===), which is what lets React skip re-rendering.
  • Optimistic. A mutation can show its expected result immediately, as a layer on top of the store, and remove that layer when the server responds.
  • Programmable. Type policies decide identity, pagination and computed fields. evict and gc() clean up, and extract/restore carry the whole store across server-side rendering.
flowchart LR
    subgraph tree["What the server sends: a tree"]
        direction TB
        L["tickets"]:::ext
        T1["Ticket T1"]:::ext
        T2["Ticket T2"]:::ext
        U1["User U7: Ada"]:::ext
        U2["User U7: Ada, again"]:::ext
        L --> T1 & T2
        T1 --> U1
        T2 --> U2
    end

    N["<b>normalize</b>"]:::write

    subgraph store["What the cache keeps: flat records"]
        direction TB
        RQ["ROOT_QUERY<br/>tickets = ref T1, ref T2"]:::store
        ST1["Ticket:T1<br/>assignee = ref U7"]:::store
        ST2["Ticket:T2<br/>assignee = ref U7"]:::store
        SU["User:U7<br/>name = Ada<br/><i>stored once</i>"]:::store
    end

    tree --> N --> store

    classDef write fill:#fde68a,stroke:#d97706,stroke-width:2px,color:#0f172a
    classDef store fill:#dcfce7,stroke:#16a34a,stroke-width:2px,color:#0f172a
    classDef ext fill:#e2e8f0,stroke:#475569,stroke-width:2px,color:#0f172a
Loading

All of this runs synchronously, in JavaScript, on the main thread, alongside your rendering and input handling. Most of the time, nobody notices.

Where it hurts

InMemoryCache has one asymmetry: reads are memoized, writes are not. A read that hits the memo is almost free. A write has no incremental path: it normalizes and compares the whole payload before it knows what changed. Then every watcher of the changed data pays for a re-read and a comparison.

From the Apollo performance guide, for a list of 5 000 entities (production build, median of five fresh runs):

What happens InMemoryCache
read the list, nothing changed 3.8 µs
write the list again with one entity changed 72.18 ms
write the list again with nothing changed 75.95 ms
re-read it after that one field changed 19.53 ms
notify 200 watchers after a relevant write (2 000 entities) 95.99 ms
memory kept for a query that is read and watched 14.8× the size of the store itself

Why milliseconds matter here. The cache runs on the main thread, the thread that also scrolls, animates and handles input. To draw 60 frames a second, the browser has 16.7 ms per frame for all of it, your JavaScript included (8.3 ms on a 120 Hz display). While a cache call runs, no frame is drawn and no click is handled. Browsers flag any task over 50 ms as a long task.

Now take one poll of that list, with one of the 5 000 tickets changed:

  1. The write: 72.18 ms. One changed field costs about as much as rewriting the whole list unchanged (75.95 ms), because the writer only finds out what changed by comparing every field.
  2. The re-read by the query that shows the list: 19.53 ms, against 3.8 µs when nothing changed, because one dirty field invalidates the memoized list.

That is about 92 ms in a single task, more than five frames at 60 fps, to show one changed field, and every poll that brings a change pays it again, however small the change. More watchers cost more: a write that 200 watchers see takes 95.99 ms, on a list less than half the size. Reads are the side Apollo memoizes, and a write-heavy application spends its time on the other side.

These numbers were measured under Node. Browser measurements come with the vertical slice (§19).

The idea

Apollo calls your policy functions (read, merge, keyFields, keyArgs) in the middle of every read and write. As long as it does, the store, the reader and the writer have to live in JavaScript next to those functions.

So we make policies data. You write key arrays and pick behaviours by name from a fixed catalogue. With no application code left inside a read or a write, the whole engine can move to Rust: the store, the writer, the reader, the result memo and invalidation.

Before: InMemoryCache. Everything is JavaScript, and your functions are called for individual fields in the middle of reads and writes.

flowchart LR
    subgraph js["JavaScript, main thread"]
        direction TB
        AC["<b>Apollo Client</b><br/>useQuery, mutations,<br/>ObservableQuery"]:::ext
        subgraph imc["InMemoryCache"]
            direction TB
            W["<b>StoreWriter</b><br/>normalize, compare, merge"]:::write
            S["<b>EntityStore</b><br/>flat records, layers"]:::store
            R["<b>StoreReader</b><br/>and the result memo"]:::read
            D["<b>dependency graph</b><br/>who read what"]:::memo
        end
        PF["<b>your policy functions</b><br/>read, merge,<br/>keyFields, keyArgs"]:::ext
    end

    AC --> W & R
    W --> S
    R --> S
    W -.->|"dirty"| D
    R -.->|"depend"| D
    W -.->|"called mid-write"| PF
    R -.->|"called mid-read"| PF

    classDef read fill:#ccfbf1,stroke:#0d9488,stroke-width:2px,color:#0f172a
    classDef write fill:#fde68a,stroke:#d97706,stroke-width:2px,color:#0f172a
    classDef store fill:#dcfce7,stroke:#16a34a,stroke-width:2px,color:#0f172a
    classDef memo fill:#e9d5ff,stroke:#7c3aed,stroke-width:2px,color:#0f172a
    classDef ext fill:#e2e8f0,stroke:#475569,stroke-width:2px,color:#0f172a
Loading

After: InMemoryCacheRs. A thin TypeScript shell keeps Apollo's API and runs the user code that works on whole operations. A Rust engine does everything that is hot. Thick arrows cross the JS ↔ WASM boundary.

flowchart LR
    subgraph js["JavaScript, main thread"]
        direction TB
        AC["<b>Apollo Client, unchanged</b><br/>useQuery, mutations,<br/>ObservableQuery"]:::ext
        CFG["<b>declarative policies</b><br/>key arrays, named behaviours:<br/>data, validated once"]:::ext
        SH["<b>InMemoryCacheRs shell</b><br/>ApolloCache API, batching,<br/>modifiers, update functions,<br/>watch callbacks"]:::api
        COD["<b>codecs</b><br/>JS objects to integer ops,<br/>node ids back to JS objects"]:::api
        AC --> SH
        CFG --> SH
        SH --> COD
    end

    subgraph rs["Rust engine, WebAssembly (same thread)"]
        direction TB
        WE["<b>write engine</b>"]:::write
        ST["<b>store</b><br/>records, layers, gc"]:::store
        RD["<b>reader</b><br/>and the result memo"]:::read
        DI["<b>dependency index</b><br/>and watches"]:::memo
        WE --> ST
        RD --> ST
        WE -.->|"dirty"| DI
        RD -.->|"depend"| DI
    end

    COD ==>|"write: one op buffer"| WE
    COD ==>|"read: returns node ids<br/>and records of new nodes"| RD
    SH ==>|"broadcast: returns<br/>the dirtied watch ids"| DI

    classDef api fill:#dbeafe,stroke:#2563eb,stroke-width:2px,color:#0f172a
    classDef read fill:#ccfbf1,stroke:#0d9488,stroke-width:2px,color:#0f172a
    classDef write fill:#fde68a,stroke:#d97706,stroke-width:2px,color:#0f172a
    classDef store fill:#dcfce7,stroke:#16a34a,stroke-width:2px,color:#0f172a
    classDef memo fill:#e9d5ff,stroke:#7c3aed,stroke-width:2px,color:#0f172a
    classDef ext fill:#e2e8f0,stroke:#475569,stroke-width:2px,color:#0f172a
Loading

Three rules carry the whole design:

  1. One store, in Rust. JavaScript keeps objects only where their identity is observable: the results the cache hands out, the JSON values you wrote, and strings.
  2. Rust never calls JavaScript. Every crossing is a call from JS into Rust that returns. User code runs between cache calls, which is where Apollo runs it already.
  3. Data crosses in bulk, as integers. A write crosses once, as a buffer of ids and numbers. A read comes back as node ids, plus records for the nodes JavaScript hasn't seen yet.

The trade

You give up You get (targets, not measurements yet)
custom read and merge functions, replaced by named descriptors that cover Apollo's pagination helpers and common idioms writes at least 2× faster, with 4× as the aim
keyFields/keyArgs functions and dataIdFromObject, replaced by key arrays broadcasts to many watchers at least 2× faster
a few rarely used features (the full list) at most half the memory per watched query
runtimes without WebAssembly evict and gc() that actually give memory back, and cache[Symbol.dispose]() for caches built per request

For a configuration that only uses keys and common policies, migrating means changing the import and how the policies are written. A policy becomes a descriptor: a plain object whose behaviour names are enums the package exports.

Before and after: seven common policies in one configuration
  import { ApolloClient } from "@apollo/client";
- import { InMemoryCache } from "@apollo/client";
- import {
-   concatPagination,
-   offsetLimitPagination,
-   relayStylePagination,
- } from "@apollo/client/utilities";
+ import {
+   Connection,
+   InMemoryCacheRs,
+   Keep,
+   ListMerge,
+   RedirectWhen,
+ } from "fast-gql-cache-rs";

- const cache = new InMemoryCache({
+ const cache = new InMemoryCacheRs({
    typePolicies: {
      Query: {
        fields: {
          // Offset pagination: offsetLimitPagination()
-         activity: offsetLimitPagination(["ticketId"]),
+         activity: { keyArgs: ["ticketId"], merge: { list: ListMerge.offset } },

          // Infinite scroll: concatPagination()
-         feed: concatPagination(),
+         feed: { merge: { list: ListMerge.append } },

          // Newest first: a list that grows at the top
          notifications: {
-           merge(existing = [], incoming) {
-             return [...incoming, ...existing];
-           },
+           merge: { list: ListMerge.prepend },
          },

          // Relay connections: relayStylePagination()
-         tickets: relayStylePagination(["status"]),
+         tickets: {
+           keyArgs: ["status"],
+           read: { connection: Connection.relay },
+           merge: { connection: Connection.relay },
+         },

          // A cache redirect: ticket(id: "T1") finds a ticket another query loaded
          ticket: {
-           read(existing, { args, toReference }) {
-             return existing ?? toReference({ __typename: "Ticket", id: args?.id });
-           },
+           read: {
+             redirect: { typename: "Ticket", keyArgs: { id: "id" } },
+             when: RedirectWhen.missing,
+           },
          },
        },
      },
      User: {
        fields: {
          // A default for a field the server may leave out
          role: {
-           read(existing = "viewer") {
-             return existing;
-           },
+           read: { default: "viewer" },
          },
        },
      },
      Ticket: {
        fields: {
          // First write wins: a value that must not change once stored
          createdAt: {
-           merge(existing, incoming) {
-             return existing ?? incoming;
-           },
+           merge: { keep: Keep.existing },
          },
        },
      },
    },
  });

  const client = new ApolloClient({ link, cache }); // unchanged

Unsupported features has the whole catalogue, with the replacement for each kind of function.

A configuration that still contains a function fails loudly: TypeScript rejects it, and the constructor throws an error that names every offending path. Nothing half-works.

You won't have to do this rewrite by hand. Once v1 is done, the project ships a migration skill: an agent skill that coding agents such as Claude Code and Cursor load, which rewrites imperative typePolicies into declarative ones and, for anything with no declarative form, points to the replacement.

How we're building it

A cache sits under every screen of an application, so "mostly works" doesn't count. The project is set up so that it cannot quietly drift from Apollo:

  • Apollo is the oracle. Apollo's own InMemoryCache test suite is ported and never rewritten to make it pass. A green run counts only for tests proven to reach Rust.
  • Byte-for-byte behaviour checks. npm run probe:parity compares our cache's output with Apollo's. Any difference is a bug, or an entry in the drift register with a measured reason and tests that pin it.
  • Evidence before engine. Faster is a hypothesis, not a promise. The two riskiest pieces, the encoder and the materializer, are prototyped and measured first, and each has a stop condition to meet before any engine work starts.
  • Every PR is measured. The benchmark label runs the performance and memory probes against main, and a nightly job records the history (benchmarking).
  • Decisions are written down. ADRs record every decision and the evidence for it, the RFC explains how they fit together, and Unsupported features lists what won't work, with a workaround for each, before anyone migrates.
  • No leaks, by contract. JavaScript's garbage collector can't see WebAssembly memory, so every cache owns its Rust allocations and frees them deterministically. A memory check that disposal returns the heap to its baseline is a release gate.

Where we are

Pre-alpha, not released. The TypeScript shell implements Apollo's full cache API and passes Apollo's own InMemoryCache test suite. So far that only proves the shell: underneath, it still delegates to Apollo's internals, and the Rust engine is a stub.

  • Phase 1: the full ApolloCache API in TypeScript, delegating to Apollo; the ported parity suite; behaviour, performance and memory probes; the benchmark workflow
  • 0. Evidence: benchmark fixes, a frozen synthetic workload (polling first)
  • 1. Boundary experiments: the encoder (E10) and the materializer (E11), then fix the thresholds and stop conditions
  • 2. The declarative profile: types, validation, a migration guide
  • 3. Vertical slice: a real ApolloClient polling through the Rust engine, measured in a browser
  • 4. v1: the full engine, passing the full oracle, followed by the migration skill that rewrites imperative typePolicies into declarative ones
  • 5. v2, the first release: no patched Apollo imports, Symbol.dispose, a clean-install check
  • 6. Beyond Apollo: improvements Apollo's model can't make, each measured on its own

The gates for each step are in RFC §20.

Measured against InMemoryCache

Each night that main has changed, a job runs the performance and memory probes on both caches, InMemoryCacheRs and Apollo's InMemoryCache, on the same machine. Each point shows how many times faster (solid lines) or smaller (dashed lines) InMemoryCacheRs is than InMemoryCache at that commit. 1× is InMemoryCache, higher is better, and the 2× line is the target for writes, broadcasts and memory from the trade. Every line sits at about 1× for now: underneath, the engine is still Apollo's.

Speed and memory of InMemoryCacheRs relative to InMemoryCache per measured commit of main. Speed: all timings, writes, reads and broadcasts. Memory: all, retained and allocated. InMemoryCache is at 1× and the goal at 2×.

Every run and every measurement is on the dnd-data/benchmarks branch. Benchmarking explains how the numbers are produced and why you can trust them.

FAQ

Can I use it today?

No. The package doesn't work outside this repository yet: it depends on a development-only patch of @apollo/client, and its WebAssembly initialization isn't built. Nothing ships for production use before v2. Join the waitlist if you'd like to know when it does.

Is it really a drop-in replacement?

For declarative configurations, that's the goal. What Apollo Client relies on holds exactly: the ApolloCache contract and synchronous read-your-writes. So does everything you write yourself: config shapes, identity, descriptor and modifier semantics, possibleTypes, and extract/restore contents. Incidental behaviour may differ, but only one registered entry at a time (ADR 0002).

If your configuration uses functions, it needs a migration first, and most functions have a declarative form: key arrays instead of keyFields/keyArgs functions, and named descriptors instead of Apollo's pagination helpers and the common merge and read idioms. The constructor names every path that still needs one. After v1, the migration skill does that rewrite for you. And if a policy of yours has no descriptor yet but solves a problem other apps share too, request a descriptor: the catalogue grows one real use case at a time.

Why can't I keep my read and merge functions?

Because they run in the middle of every read and write, often once per field. Calling from Rust back into JavaScript that often would cost more than the engine saves, and it would keep the reader and the writer tied to JavaScript. Removing that one capability is what lets everything else move to Rust.

Most policies don't need to be functions, though. Unsupported features lists each function-based feature with its declarative replacement, and after v1 the migration skill applies those replacements for you. If a policy of yours has no replacement yet but expresses a pattern other apps are likely to share, request a descriptor that describes the policy and what it does. The catalogue grows by amendment, one real use case at a time. What stays out is logic that computes values, such as a computed field, and that list gives the alternative for each.

Why not move the cache to a Web Worker?

Every cache API is synchronous. Apollo Client writes a result and reads it back in the same call stack, and a worker can't answer synchronously. The engine runs on the main thread, like InMemoryCache. The goal is for it to do less work there, not to move the work elsewhere.

Why Rust and WebAssembly, rather than a faster JavaScript cache?

Partly because exploring Rust and WebAssembly is the point of this project: a pure-JS engine is out of scope. The declarative profile is also what makes a compact engine possible, since results become integer records instead of a graph of JavaScript objects. Where Rust and WebAssembly would lose, for example by walking JS objects property by property or by copying every string across the boundary, the design keeps that work in JavaScript. The alternatives considered list the rest.

Will it actually be faster?

We don't know yet, and we won't claim it until it's measured. The targets are at least 2× on writes and broadcasts, half the memory per watched query, and warm reads within 2× of Apollo's few microseconds. The boundary experiments come first, so if the crossing costs too much, the work stops before any engine is built (RFC §19).

Does WebAssembly mean a setup step, or an async constructor?

Neither. new InMemoryCacheRs() is synchronous, like new InMemoryCache(). The WebAssembly ships inside the package and compiles the first time you create a cache, and CI keeps it within a size budget (ADR 0003). This needs a runtime with WebAssembly, Chrome 115 or later on the main thread, and 'wasm-unsafe-eval' in any Content Security Policy (U9–U11).

I build a cache per request on the server. Will it leak?

It must not, and v2 doesn't ship until it's proven. JavaScript's garbage collector can't see WebAssembly memory, so cache[Symbol.dispose]() (or using cache = …) frees everything a cache allocated, deterministically. A finalizer is only a fallback.

Which Apollo Client versions?

@apollo/client@4.2.11, pinned as both the development and the peer dependency. Its test suite is the oracle, so upgrading is its own change, re-verified against the new oracle.

Is this an official Apollo, Rust or WebAssembly project?

No. It is an independent open-source project, not affiliated with or endorsed by Apollo Graph, Inc., the Rust Foundation or the W3C WebAssembly Community Group. It builds on Apollo Client and uses Apollo's test suite as its measure of correctness.

The logos at the top belong to their owners and appear only to say what the project is built from: the Apollo wordmark (recoloured white for dark themes), the Rust logo by the Rust Foundation under CC-BY 4.0, and the WebAssembly logo and Ferris the crab, both dedicated to the public domain under CC0. Sources are listed on the dnd-data/assets branch.

How can I help?
  • Read RFC 0001 and weigh in on its open questions.
  • Have a write-heavy workload? Describe its shape in a comment on the waitlist issue: payload sizes, polling rate, how many watchers. The synthetic workload is frozen before the engine is built, so real shapes are most useful now.
  • Check your cache configuration against Unsupported features and tell us what would block you.

Support the project

  • Join the waitlist. React with a thumbs-up on the waitlist issue, and subscribe to it if you'd like a notification when the first release ships. The badge at the top counts them.
  • Sponsor the work. GitHub Sponsors helps fund the time it takes to build the engine and prove it against Apollo's own tests.
  • Help shape it. See How can I help? in the FAQ.

Go deeper: the research on Apollo's InMemoryCache

Before writing any Rust, we took Apollo's InMemoryCache (@apollo/client@4.2.11) apart: how every path works, what each one costs, and what a replacement has to preserve. These guides describe Apollo's cache, not InMemoryCacheRs; the design of InMemoryCacheRs is RFC 0001. They record what we've found so far and will change as the work goes on. The research lists every chapter, and the documentation home has reading paths and a section-level table of contents.

Apollo architecture guide: what every path does

Apollo performance guide: what every path costs

Probes: executable checks

  • Behaviour probe: 78 assertions that pin the behaviour described in the Apollo architecture guide
  • Performance probe: produces every table in the Apollo performance guide; its output is committed as cache-performance-probe.log
  • Benchmarking: how each PR's performance effect is measured (the benchmark label) and tracked nightly on the dnd-data/benchmarks branch

Setup dev environment

Versions are pinned: Node in .nvmrc, Rust (with the wasm32-unknown-unknown target, rustfmt and clippy) in rust-toolchain.toml, dependencies in package-lock.json and wasm/Cargo.lock. You need git, nvm and rustup.

git submodule update --init --recursive --depth 1
nvm install        # the Node version in .nvmrc
npm ci
# Installs the Rust toolchain rust-toolchain.toml pins. RUSTUP_PERMIT_COPY_RENAME
# avoids an EXDEV rename failure on overlayfs-backed containers (cloud CI/agents).
RUSTUP_PERMIT_COPY_RENAME=true rustup toolchain install
npm run wasm:dev   # builds pkg/, which typecheck and tests import
npm test

CI (.github/workflows/ci.yml) runs the same steps.

License

  • Code (everything that is not documentation, including the npm package and the Rust crate): dual-licensed under MIT or Apache-2.0, at your option. This is the convention across the Rust ecosystem.
  • Documentation (this README's prose, docs/ and its diagrams): CC-BY 4.0. Reuse it anywhere, with credit to fast-gql-cache-rs and a link back. Code samples in the documentation are also available under the code licenses, so you can paste them without attribution overhead.
  • Third-party material: code adapted from Apollo Client stays under Apollo's MIT license, and the logos belong to their owners. See THIRD_PARTY_NOTICES.md.

Unless you say otherwise, any contribution you submit is licensed the same way as the file it changes: code under MIT OR Apache-2.0, documentation under CC-BY 4.0, with no additional terms.

About

Apollo Client's normalized cache, with the engine rebuilt in Rust and compiled to WebAssembly.

Topics

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages