A GraphQL mutation returns the created object, but a cached list still omits it. Why?
answer
- A list field is stored as references
- The created entity is there but unreferenced
- Membership lives on the container, not the object
- Return the container, patch it, or refetch
- Deletion leaves references dangling
basics
~20 sMembership belongs to the list field, not to the created object. A normalized store writes the new entity, but nothing appends a reference to it in the stored list, so the screen renders the old membership until the list is patched or refetched.
solid answer
~50 sA normalized client cache holds a list field as an ordered array of **references** to entities. Creating an object writes the entity itself, which no screen is reading yet; the array that the screen renders is unchanged, because nothing in the response says that array grew. Returning the created object therefore cannot fix a create - membership is a property of the *containing* field, and only a write to that field changes it. There are three ways out: return enough of the container in the payload for the client to update it, write the reference into the stored list explicitly, or refetch the operations that read the list. Deletion is the mirror problem: the entity goes but references to it linger. Paging makes it worse, because one logical list is stored as several entries keyed by their arguments, and each is a separate place to repair.
code
graphql · 9 linesmutation JoinWaitlist($eventId: ID!, $fanId: ID!) {
joinWaitlist(eventId: $eventId, fanId: $fanId) {
waitlistEntry { id position createdAt }
event {
id
waitlistEntries { id position }
}
}
}go deeper
Remember the distinction: a mutation that returns the changed object fixes its field values, but a create or delete changes which objects a list contains, and that has to be repaired separately.
Explain that a list field is stored as an array of references on the containing entity, and lay out the three repairs - return the container, write the reference into the stored list, refetch the operations - with the cost of each.
Show judgement about ordering, filtering and paging: any list whose membership or order the client cannot reproduce is a refetch. Be ready to talk about deletion leaving dangling references and about which stored variants a patch misses.
Own the standing rule rather than the individual case - which repair is the default, what makes a call site allowed to hand-patch the store, and how the team knows which operations a given mutation affects at all.
## Two different things get called "the cache is stale" In a normalized store, a query result is a tree of references, and a list field is stored as an ordered array of them. So `Event.waitlistEntries` on `Event:ev-4471` is not a list of objects; it is `[WaitlistEntry:we-8802, WaitlistEntry:we-8814, ...]`, and the screen renders whatever that array currently holds by dereferencing each element. That splits staleness into two unrelated cases: - **A value changed** on an object the store already holds. Returning that object from the mutation repairs it, because the store merges by identity. - **Membership changed** - an object joined or left a list. No amount of returning the object repairs this, because the fact that changed lives in the *array*, on a different entity, and the response never mentioned it. Candidates who have only ever written update mutations tend to assume the first mechanism covers the second. It does not, and no payload shape fixes it by itself. ## The ticketing example A fan joins the waitlist for a sold-out show: ```graphql mutation JoinWaitlist($eventId: ID!, $fanId: ID!) { joinWaitlist(eventId: $eventId, fanId: $fanId) { waitlistEntry { id position createdAt } } } ``` `WaitlistEntry:we-9137` is written to the store. `Event:ev-4471`'s `waitlistEntries` array still holds the 214 references it held a moment ago. The waitlist screen re-renders nothing, because nothing it references changed. The new entry exists in the cache and is invisible - which is the confusing part, and the thing to say out loud in an interview. ## The three repairs, and what each costs **1. Return the container, not just the object.** The payload can select the parent and its list, so the server's array arrives and replaces the stored one: ```graphql mutation JoinWaitlist($eventId: ID!, $fanId: ID!) { joinWaitlist(eventId: $eventId, fanId: $fanId) { waitlistEntry { id position } event { id waitlistEntries { id position } } } } ``` This is genuinely correct for small, bounded, unparameterised lists, and it costs no extra round trip. It stops working the moment the list is large or paged: the server does not know which slice this client is showing, and returning all 214 entries to add one is not a trade you make twice. A middle ground many teams use is to return the new element plus the container's `id`, leaving the client to splice - which is really repair (2) with the server supplying the material. **2. Write the reference into the stored list.** The client asks the store to update the list field on the container directly, appending the reference from the mutation result: ```pseudocode onCompleted(result): store.updateField(entity = "Event:ev-4471", field = "waitlistEntries", update = existing -> append(existing, ref(result.waitlistEntry))) ``` Cheapest at runtime - no request, immediate - and the most brittle. The call site now encodes which container and which field the write affects, and sorting and filtering are the client's problem: if the list is ordered by `position`, appending puts the entry in the wrong place; if the list is filtered to `status: ACTIVE`, only sometimes should it be appended at all. When the list is paged, one logical list is stored as several entries keyed by their arguments, so "the list" is several places, and choosing which of them to patch is a separate problem in its own right. **3. Refetch the affected operations.** Ask the client to re-run the documents that read the list, and let the server be the authority on membership, order and filtering. Costs one or more round trips and settles later than the mutation response, so the UI needs to either wait for it or tolerate a brief window. It is the only repair that is correct by construction, and it is the right default for anything whose ordering or filtering the client cannot reproduce. ## Deletion is the mirror image A delete mutation typically returns just the deleted id. The store may drop `WaitlistEntry:we-9137`, but every array that referenced it still does. Stores differ in what a dangling reference does on read - some filter it out of the list silently, some surface an incomplete result - and neither is something to rely on. Removing the reference, or refetching, is the same three-way choice as above. ## What to say in an interview State the invariant first: *returning the changed entity repairs values; only a write to the containing field repairs membership.* Then give the three repairs with their costs, and pick one for the case in front of you - bounded list, return the container; ordered or filtered or paged list, refetch; hot path with a shape you fully control, patch the store and accept the maintenance. The wrong answer is not choosing badly; it is not knowing that a choice exists.
- Why not simply have every create mutation return the whole updated list?It works for a short, bounded, unparameterised list and is the simplest correct option there. It breaks down as soon as the list is large - you ship hundreds of elements to add one - or paged and filtered, because the server does not know which slice or which filter arguments this particular client is holding. Then you are returning a list that matches nobody's cached entry, and the screen is still stale.
- A delete mutation returns only the deleted id. What does the cache do about the lists that referenced it?Nothing automatic. Dropping the entity does not remove the references, so every stored list still points at it. What a read then does is store-specific - some filter dangling references out, some hand back a hole - and neither behaviour is a contract to build on. Remove the reference explicitly, or refetch the operations that read the list, exactly as with a create.
- The list is sorted by the server and filtered by a status argument. Does that change which repair you pick?Strongly. Patching the store means reimplementing the server's ordering and its filter predicate on the client, and keeping both in step with the backend forever. Any list whose position or membership depends on logic the client cannot reproduce is a refetch, not a splice. Reserve direct store writes for lists whose shape you fully control, such as a plain append-ordered feed.
Filing a new document in the cabinet does not add it to the index card that the reading room actually consults. Somebody has to write the card, or reprint the index from scratch.
saying these in an interview costs you the question
- Thinks returning the created object appends it to lists automatically
- Believes the store re-runs list queries when a new entity of that type appears
- Assumes deleting an entity removes it from every cached list
- Says the server should always return the whole list
- Splices into a sorted or filtered list without reproducing the server's rule
- Patches one stored list entry and ignores the other argument variants