Axon 911 Frontend Docs
    Loading navigation…
U
User
Switch to light mode
  1. Working With Data
  2. Graphql Cache

GraphQL Cache

Cache identity, updating the cache after mutations and subscriptions, eviction, and tests

Every remote in a shell reads from one Apollo cache. The shell builds one client with createApolloClient, and createPreparedInMemoryCache (packages/util-apollo/src/cache.ts) creates its InMemoryCache. That cache sets possibleTypes, from the schema's introspection, so fragments on interfaces and unions match, and keyFields for the few types listed in packages/data-gql/src/cache-key-fields.json (AliRecordParseResult, DispatchChatroomList and TextCall today). It sets no field policies, so lists replace on every write. Every other type uses Apollo's default identity. The examples on this page are illustrative. Message, messageAdded, Transmission, transmissionAdded and resolveChatroom aren't in the vendored schema, and the real Chatroom.messages is a paginated connection rather than a plain list, so check names and shapes against packages/data-gql/src/schema.graphql before copying.
Quick reference
You want to…Use
Change fields on an entity you already haveNothing. Return the entity's id and the changed fields from the mutation or subscription, and the cache merges them
Add an entity to a list, or remove one from itcache.modify on the list field, de-duplicating by reference
Rewrite a query's result in one gocache.updateQuery
Write a whole entity you built yourselfcache.writeFragment
Remove a deleted entity everywherecache.evict({ id: cache.identify(entity) }), then cache.gc()
Make the server recompute something you can't (counts, filters, server-side sorting)A narrow refetch, after the cache update (see Mutation order)
Show the result before the server answersoptimisticResponse
Find out why useFragment says complete: falseWhy is useFragment incomplete?
Keep a list that updates all shift from growingLong-lived live lists
Identity
InMemoryCache stores each object that has a __typename and an id (or _id) once, under the key Typename:id, for example Chatroom:42. Every query, fragment, mutation, and subscription result that contains Chatroom:42 reads and writes that one entry. That is what makes updates show up everywhere without extra code.
  • Select id. The client adds __typename to every selection set, but it only knows id if you ask for it. The lint gate's require-selections rule makes every document select id on a type that has one.
  • A type without id isn't normalized. Its object is stored inside the field that returned it, as part of its parent. Two queries that return it can't share it, and useFragment can't find it on its own. The schema has many such types, mostly value objects and list wrappers (for example EmergencyContact or DispatchChatroomList). Read them through the parent's fragment.
  • keyFields is the type policy that gives a type a different identity, such as keyFields: ["code"], or ["dispatchCenter", ["id"], "code"] for a nested key. Every document that returns the type must then select those fields; a write without them fails with Missing field '…' while extracting keyFields. keyFields: false stops a type from being normalized at all. We have three today, listed in cache-key-fields.json. Add one only when a type without id has a stable key and two screens need to share it. Add it to that file, which createPreparedInMemoryCache reads, with a test, never in a remote: the cache is shared, so a type policy applies to every remote in the shell. If a new type needs a key only because the backend left id off, consider asking the backend for an id instead (see Backend Responses).
  • cache.identify(object) returns the cache key for an object ("Chatroom:42"), or undefined when it has no identity. Use it rather than building the string by hand: it follows a type's keyFields.
Why is useFragment incomplete?
useFragment reads a fragment for one entity straight from the cache. It returns complete: true only when every field of the fragment is there; otherwise complete is false, data is partial, and missing says which fields are absent. There is no error. A component that checks complete (as it must) renders nothing. The usual causes, in order:
  1. The entity has no identity. The object passed as from has no id, because the type has none or the parent didn't select it. useFragment then has no cache key to read.
  2. The parent didn't spread the fragment. The query selected the entity but not ...Child_prop, so the child's fields were never fetched. With masking the parent can't read them either, so this shows up only as an incomplete child.
  3. The entity isn't in the cache. The query is still loading, or failed, or the component reads by id (from: { __typename: "Chatroom", id }) before any query has fetched that entity. A null from is also incomplete.
  4. The query didn't write to the cache. A query with fetchPolicy: "no-cache" never stores its result, so no child of it can read a fragment.
  5. A field's arguments differ. The cache stores messages(first: 10) and messages(first: 20) as two fields. A fragment that asks for different arguments from the ones the query fetched finds nothing.
  6. The entity was evicted, or a field on it was, after the query ran.
  7. The server returned an error for that field. With errorPolicy: "all", the data is partial and the field is null or missing.
missing names the absent fields. Read it in the debugger before guessing. Don't cast your way past complete. If the data can arrive later, useSuspenseFragment suspends until it is complete instead.
Updating the cache
The cache APIs are never masked: readFragment, writeFragment, updateQuery, updateFragment, and modify see and write whole objects, whatever the components' fragments hide.
Changing an entity: nothing to do
When a mutation or subscription returns an entity with its id and the fields that changed, Apollo writes the result to the cache and merges those fields into the existing entry. Every query and fragment that reads them re-renders. Subscriptions do this too: a useSubscription result is written to the cache before your component sees it (unless fetchPolicy is "no-cache").
Select every field the mutation changes. A field you leave out keeps its old value in the cache.
Lists: cache.modify
Apollo can't know that a new message belongs in Chatroom.messages, or that a deleted one has left it. Change the list field with cache.modify, and de-duplicate by reference. The same entity can arrive twice: from a subscription and from your own mutation, or from a refetch.
  • id picks the entity, and fields maps each field name to a function that gets the stored value and returns the new one. A list of entities is stored as a list of references ({ __ref: "Message:7" }), so compare with readField("id", ref), not by object identity.
  • The modifier also receives DELETE, which removes the field, and INVALIDATE, which marks it stale and notifies its watchers without changing the value.
  • A field with arguments is stored once per set of arguments. modify runs your function for every stored variant of messages, so it must make sense for each of them.
  • cache.modify doesn't add a field that isn't in the cache yet. If no query has fetched messages for that chatroom, there's nothing to update, and the next query fetches the fresh list.
A whole query result: cache.updateQuery
When the change is easier to describe as "this query's result, but different", updateQuery reads the cached result, passes it to your function, and writes back the new object you return. The data it passes is read-only, so build new objects rather than mutating it; return nothing to leave the cache as it is.
It changes only that query with those variables. To change a list wherever it appears, use modify on the owning entity's field. cache.updateFragment is the same tool for one entity's fragment.
An entity you built: cache.writeFragment
writeFragment writes a fragment's fields for one entity, for example data that arrived outside GraphQL. Include __typename and id, or pass from (or id) to say which entity it is. It returns the entity's reference. A fragment used only for writeFragment is fine; it doesn't need a component.
Mutation order
Reach for these in order, and stop at the first that does the job:
  1. The payload returns the changed entities. The mutation's selection includes each changed entity's id and changed fields, and the parent that owns a list it changed. Apollo merges them into the cache and every reader updates. If the payload returns only ok or a scalar, ask the backend for the entities (see Backend Responses); until then, select the fields you can and say in a comment why the cache is stale.
  2. update with cache.modify, for what a payload can't express: adding to or removing from a list, as above.
  3. A narrow refetch, only for what the client can't compute (aggregates, server-side filtering or sorting).
A refetch is the last step, not the default. It costs a round trip, and it can reach further than you expect (see below).
When to refetch instead
refetchQueries sends the queries again and replaces their results. It costs a round trip, and the user waits for it, but it is right when the client can't compute the answer:
  • an aggregate, such as a count or a total;
  • a list the server filters, sorts, or paginates, where you can't tell where (or whether) the new item belongs;
  • a mutation with effects on many queries that you can't enumerate.
Pass the Documents, not their names as strings: a name with a typo refetches nothing, with only a development warning (Unknown query named …). Set awaitRefetchQueries: true when the mutation should stay loading until the refetch finishes. Prefer a cache update when the mutation already returns everything you need. Narrow it. refetchQueries: [Doc] reaches every active query on Doc, in every component (see what reaches other components). Never use include: "all": it refetches every active query in the shell. To refetch less:
onQueryUpdated runs once per affected query and decides whether to refetch it; returning false skips it. To mark one field stale instead of refetching a whole query, evict the field and let the queries that read it fetch it again:
Don't refetch a list from a subscription handler. Each event would cost a round trip and replace the list, so write the event's entity to the cache as above, and ask the backend for an event that carries it.
Removing data
Eviction
evict removes the entity. Lists that referenced it drop the dangling reference automatically when they're read, so you don't need to edit each list. A single (non-list) field that pointed at the entity becomes missing, and a cache-first query that reads it fetches again. cache.gc() then removes everything nothing can reach any more. cache.evict({ id, fieldName: "messages" }) removes one field instead. Each active query that reads it fetches it again. That's a precise way to say "this list is stale" without refetching everything.
Optimistic responses
Apollo writes the optimistic result to a separate layer, renders it at once, and replaces it with the server's answer. If the mutation fails, it discards the layer and the UI goes back by itself. You still show the failure (see Error Handling). The optimistic object must have the shape of the mutation's whole result, including __typename and id. Masking doesn't apply to it. optimisticResponse can also be a function (variables, { IGNORE }) => …; return IGNORE to skip the optimistic update for that call. A mutation's update function runs for the optimistic result and again for the server's, so a list change made there must be idempotent: de-duplicate as above.
Type policies and list merges
A type policy is configuration on InMemoryCache: keyFields for identity, and fields with read and merge functions per field. We have keyFields for three types and no field policies. The rules if you need one:
  • It goes in createPreparedInMemoryCache (or, for a keyFields entry, cache-key-fields.json) with a unit test, not in a remote, because one cache serves every remote in the shell.
  • Lists replace by default. When a query returns messages again, the new list replaces the old one. That is right for a full list, and wrong for pages: page 2 would replace page 1. Paginated fields need a field policy with keyArgs (the arguments that identify different lists, as opposed to pages of one list) and a merge that combines pages. @apollo/client/utilities ships offsetLimitPagination, relayStylePagination, and concatPagination for the common shapes.
  • An object without id that two queries return differently is replaced, not merged, and Apollo warns Cache data may be lost when replacing the … field. The fix is to give the type an identity (keyFields), or merge: true on the type when it's safe to merge its fields.
Long-lived live lists
A dispatcher console stays open for a whole shift, with call lists, chat lists and feeds that update all the time. What a screen writes to the cache stays there until code removes it. Apollo never evicts on its own, and cache.gc() runs only when something calls it; no shell code calls it today. A list that gains entries and never loses any grows until the tab closes. Three decisions keep it bounded: where the list lives, what removes entries, and how to add one without adding it twice.
Where the list lives
Ask who else reads an item.
In the cacheIn component state
Use it whenOther components read the same entity: a badge count, a detail pane, a child's useFragment. Call lists and chat listsOne component shows an ephemeral event feed that nothing else reads, such as a radio log
How events arriveThe subscription result is written to the cache and merges into the entity by id; list membership is a cache.modify (Lists)The subscription uses fetchPolicy: "no-cache", and onData appends to state. Nothing is written
An ephemeral feed:
Capping the state is not enough on its own. With the default fetch policy every event is also written to the cache as its own entity. Apollo keeps only the latest event under the subscription's root field, so the earlier ones are unreachable but still stored, and they stay until cache.gc() runs. With a plain InMemoryCache, 1,000 events leave 1,001 entries, and cache.gc() removes 999 of them. fetchPolicy: "no-cache" writes nothing, and the component still receives every event. The exception is a child that reads the item through a fragment. With data masking on, the fields behind a spread fragment are not in the subscription's data, and no-cache leaves the child nothing to read with useFragment (see Why is useFragment incomplete?). Select the fields a feed renders directly in the subscription document, with no ...Component_prop spread. If a child needs the fragment, the list belongs in the cache: read on.
A list in the cache must shed what leaves it
Every event adds an entity and a reference to the list. Nothing takes either away unless your code does: a list field changes only when you change it or a response replaces it, and an entity stays in the store after nothing points to it. The cache then grows with the length of the shift, every cache.modify walks a longer list, and the cache monitor reports more entities each hour. Make whatever adds to the list also remove from it:
  • A delete or close event removes the entity. Evict it, then collect (see Eviction). The stored list keeps the dangling reference until its next write, so filter with canRead in the next cache.modify. The event needs the id for this, so ask the backend for a REMOVED kind that carries it (see Backend Responses).
  • An item that rolls off a cap or a window (the newest 100, the last 24 hours) leaves by dropping its reference in the same cache.modify that adds the new one; cache.gc() then removes the entity if nothing else references it. An entity you wrote yourself with cache.writeFragment is the exception: the cache retains every entity written directly, so gc skips it. Evict it, or call cache.release(id) first.
  • Whoever removes calls cache.gc(), once per batch. It walks the whole store, which is shared by every remote in the shell, and returns the ids it removed. Don't call it on every event.
  • A merge can cap a list that a response replaces; a read cannot. merge: (_existing, incoming) => incoming.slice(0, MAX) caps the stored list for query results and writeQuery, and gc then collects what fell off. merge does not run inside cache.modify, and a subscription that returns one entity never reaches it, so the modify that adds must apply the cap itself. A read function that slices only changes what a query returns: the stored list and its entities stay. The pagination helpers in @apollo/client/utilities (concatPagination, offsetLimitPagination, relayStylePagination) join pages and have no limit. A field policy applies to every remote in the shell, so it goes in createPreparedInMemoryCache with a test (see Type policies and list merges).
Adding to a list twice
The same entity can reach a list from a subscription, from your own mutation and from a cache-and-network refetch, and nothing orders the three. Two orders reproduce in a plain cache. If an event arrives after a refetch that already included its item, a cache.modify that prepends without looking adds the item again. If a refetch the server answered before the event arrives after it, the refetch replaces the list and the item the event added is gone. In May 2026 Dispatch showed duplicate chatrooms when a subscription's cache.modify and a concurrent cache-and-network refetch wrote the same list; the exact interleaving was never confirmed, so write the code so that no ordering matters.
  • Guard every add by identity, and cap in the same step. Skip the item if the list already holds its id (the guard from Lists). Filter out references whose entity was evicted before the cap, or they take a slot. Without an id in cache.modify, the field is ROOT_QUERY's; pass the owner's id for a field on an entity.
    A bare field name runs against every stored variant of the field, one per set of arguments. If only some variants should take the item, return existing unchanged for the others (storeFieldName is in the modifier's second argument).
  • Guard a merge that appends too. A merge that adds pages or events is a second write path, and the modify guard does not run in it. De-duplicate there by id as well. Don't merge a full list's incoming result into the existing one: a refetch should replace the list, so items the server no longer returns leave it.
  • Catch up once on mount, then leave a live list to its subscription. nextFetchPolicy: "cache-first" stops the refetches (see Freshness and Loading States); a refetch while the subscription is live is how a stale answer replaces newer events. When a refetch is unavoidable, such as a filter change, treat its answer as the list.
  • Don't de-duplicate at read time. A hook that filters repeated ids while rendering hides the symptom, and the list still holds both references.
Testing a live list
A list that grows from events is invisible to a test that sends none. The memory lab's WebSocket sends no events, so a clean lab reading says nothing about a live-updating screen (see "Memory lab" in TESTING.md). To check that a list stays bounded, deliver events: push them in a component test (Testing Subscriptions), or add an Apollo cache benchmark beside longShift (see "Apollo cache benchmarks" in TESTING.md).
Cache behaviour in tests
renderWithApollo (from @prepared911/util-testing/apollo) builds a real client with createApolloClient, so tests use the same cache as the app: createPreparedInMemoryCache, possibleTypes, data masking, and cache-first queries.
  • A fresh cache per render. Each renderWithApollo call, and each render under the WithApollo wrapper, creates a new client and cache, so nothing leaks between tests. The returned client is that test's client: read its cache with client.cache.extract(), client.readFragment(…), or client.readQuery(…) to assert on a cache update.
  • Seeding. To start from data that's already cached, build a cache, write to it, and pass it in: renderWithApollo(ui, { apollo: { cache } }). Data written with cache.writeQuery or cache.writeFragment needs __typename and id; the factories in @prepared911/data-gql/factories include both. A cache-first query that finds everything it needs makes no request.
  • Shared entities. The schema-driven mock handlers generate values, including ids. If a test needs two operations to meet on one entity (a mutation that updates what a query showed), pin the id with a mock override so both responses identify the same object.
  • Updates are asynchronous. useFragment re-renders on a later tick after a cache write, so wait with findBy… or waitFor rather than asserting straight after the write.
Related documentation
  • GraphQL Fragments - Component-owned fragments, masking, and useFragment
  • Freshness and Loading States - How fresh data reaches the cache and how components show a pending query
  • Backend Responses - What a mutation or subscription should return
  • GraphQL Operations - Queries, mutations, and error handling
  • Pub/Sub - Subscriptions and live updates
  • Apollo's caching docs for API details. Apply this repo's layout and import rules to anything you take from them.
Previous
Working with data / GraphQL Development Workflow
Next
Working with data / GraphQL Freshness and Loading States
On this page
Quick reference
Identity
Why is useFragment incomplete?
Updating the cache
Changing an entity: nothing to do
Lists: cache.modify
A whole query result: cache.updateQuery
An entity you built: cache.writeFragment
Mutation order
When to refetch instead
Removing data
Eviction
Optimistic responses
Type policies and list merges
Long-lived live lists
Where the list lives
A list in the cache must shed what leaves it
Adding to a list twice
Testing a live list
Cache behaviour in tests
Related documentation