skip to content

Why does returning the updated object from a GraphQL mutation refresh a normalized client cache?

level: juniorimportance: must knowfreq 68%

answer

  1. The store is keyed by identity
  2. A mutation response is shredded like any other
  3. Same id, new field values, merged in
  4. Unselected fields are not refreshed
  5. Values, never list membership

basics

~20 s

A normalized store keys objects by type and id, not by the operation that fetched them. When a mutation's response carries the same id with new field values, the store overwrites that entity, and every view reading it updates.

solid answer

~50 s

A normalized client cache shreds every response into entities addressed by identity - conventionally the type name plus the object's `id` - so a value written by one operation is the same value read by all the others. A mutation response goes through exactly the same shredding. If the payload selects the changed object's `id` **and** the fields whose values changed, the store merges those fields onto the existing entity and notifies every active operation that reads it. Nothing re-runs against the server. Two consequences follow. Fields the payload did not select are not refreshed - they keep whatever the store already held, which is why a partial payload silently leaves stale values behind. And it only works for **value** changes to an object the store already holds by that identity: it cannot tell any list that its membership changed.

code

graphql · 10 lines
graphql
mutation ShiftDoors($id: ID!, $at: DateTime!) {
  updateEventDoorsOpenAt(eventId: $id, doorsOpenAt: $at) {
    event {
      id
      doorsOpenAt
      salesCloseAt
      updatedAt
    }
  }
}

go deeper

for a junior

Recall the one-line reason: the cache stores objects by identity, so a mutation returning the same id with new values updates every screen reading that object. Be ready to say why the payload must select id.

for a middle

Explain the merge precisely - only the fields the payload selected are written, the rest of the entity is untouched - and name the three failure modes: missing id, an unselected changed field, and a change that landed on a different entity.

for a senior

Show that you treat the payload selection as the invalidation contract for the write, and that you know its hard limit: values only, never membership. Be ready to say what you escalate to and what that costs in round trips.

for a principal

Own the tradeoff between the cheapest correct mechanism and blanket refetching, and the fact that identity-keyed caching is a client convention the schema has to cooperate with - a stable identifier on every mutable type is a platform decision, not a per-feature one.

## The mechanism, stated precisely A normalized client cache does not store responses. It shreds each response into individual objects and stores them in a flat map addressed by identity - by convention the object's type name combined with its `id`, though the exact key construction is the store's business. Each operation's result is kept as a tree of *references* into that map. Two different documents that both fetched event `ev-4471` point at one stored entity, not at two copies. That single fact is the whole answer. A mutation response is not special. It is parsed, shredded and merged into the same map by the same rules. Writing new field values for `Event:ev-4471` is therefore indistinguishable, from the store's point of view, from a query having fetched them - and every screen whose active operation references `Event:ev-4471` is re-delivered the merged entity. None of this is in the GraphQL specification. GraphQL specifies how a document is executed and what the response envelope looks like; identity-keyed client caching is a widespread client-side **convention**, and the Global Object Identification convention exists partly to give such a cache a durable key to use. ## A worked example A ticketing graph. `Event` is a wide type - 37 fields covering the billing, marketing and box-office concerns that accreted onto it - and one of them is `doorsOpenAt`. ```graphql mutation ShiftDoors($id: ID!, $at: DateTime!) { updateEventDoorsOpenAt(eventId: $id, doorsOpenAt: $at) { event { id doorsOpenAt updatedAt } } } ``` The response comes back: ```json {"data":{"updateEventDoorsOpenAt":{"event":{ "id":"ev-4471","doorsOpenAt":"2026-09-14T18:30:00Z","updatedAt":"2026-09-03T11:02:41Z"}}}} ``` The store finds it already holds `Event:ev-4471` with 37 fields, merges the three that arrived over the three it had, and leaves the other 34 alone. The box-office screen, the venue-schedule screen and the printed-ticket preview all read `doorsOpenAt` off that entity, and all three re-render. No query was re-issued; no round trip was spent. ## The three ways this quietly fails **The payload omits `id`.** Without the identity field, the store cannot match the returned object to the one it holds. Some stores then key it by the path it arrived at - effectively a private copy under the mutation result - and the screens keep reading the old, still-stale entity. Selecting `id` in every mutation payload is the cheapest habit on this list. **The payload omits a field that changed.** Suppose the server also recalculates `salesCloseAt` whenever the doors time moves. The document above never asked for it, so the store keeps the old value and one screen shows an inconsistent pair. The client only learns about changes it explicitly selected; a mutation cannot push a field nobody asked for. **The changed object is not the one the screen reads.** Shifting the doors time may change a derived `Venue.tonightSchedule` summary computed by the server. That value lives on a different entity, so refreshing `Event:ev-4471` does nothing for it. Every field on every *other* entity that the write invalidated is invisible to this mechanism, and is exactly what an explicit refetch exists to repair. ## What it categorically cannot do Returning the changed entity fixes **values on an object the store already has**. It does not change **membership**: creating a ticket writes `Ticket:tk-9930` into the map, but nothing appends a reference to it into the `Event.tickets` list that some screen is rendering, so the new ticket is invisible until that list is repaired or refetched. Deleting is the mirror image - the entity may be dropped while stale references to it linger in lists. That membership gap is the reason mutation invalidation is a topic at all rather than a solved problem. ## Why this shape is worth insisting on Returning the changed entity is the cheapest correct invalidation available: it costs no extra round trip, it is impossible to get out of order with the write it belongs to, and it needs no client-side knowledge of which screens are open. Compare it with the alternative of refetching every affected operation, which costs one or more extra requests, lands after an arbitrary delay, and can race the very write that triggered it. The practical rule is: make the payload return the object you changed, selecting `id` plus every field the write can move, and treat refetching as the escalation for what that cannot express.

  • The mutation returned the changed event, but one screen still shows the old doors-open time. What do you check first?
    Two things, in order. Did the payload select `id`? Without it the store cannot match the returned object to the entity the screen reads, so it lands as an unrelated copy. Then, did the payload select the field that screen renders? A store only merges the fields that arrived; anything unselected keeps its old value. Both faults look identical from the UI and both are fixed in the document, not in the store.
  • Does this still work when the mutated object was never in the cache before?
    The object is written to the store either way, but writing it is not the same as showing it. A screen renders what its own operation's result tree references, and a brand-new entity is referenced by nothing. So a create is not repaired by returning the entity - the list that should contain it has to be patched or refetched. Updating an object already on screen is the case the mechanism actually covers.
  • What happens with a type that has no id field?
    The store has no identity to key on, so it cannot merge the returned object with anything - it typically falls back to storing the value inline under the path where it appeared, which means the mutation result and the query result stay separate copies. The fixes are to expose a stable identifier on the type, or to configure the store with an alternative key for it. Value-object types with no identity are legitimately non-normalizable and simply do not get this behaviour.

The store is a filing cabinet with one folder per object, and every screen holds a pointer to a folder rather than its own photocopy. A mutation that returns the object just files new pages in the folder everyone is already pointing at.

saying these in an interview costs you the question

  • Thinks the client re-runs every query after any mutation
  • Believes the store matches on the mutation field name, not identity
  • Omits id from the payload and still expects a merge
  • Assumes fields the payload did not select are refreshed too
  • Claims returning the entity also fixes lists containing it
  • Calls identity-keyed client caching part of the GraphQL specification

context