skip to content

Why does a field read after a mutation return the loader's stale pre-mutation value?

level: seniorimportance: should knowfreq 42%

answer

  1. The write and the memo entry never meet
  2. Something read the key before the write
  3. Two operations: forget it, or replace it
  4. Priming a present key may do nothing
  5. Invalidate beside the write, not in the payload

basics

~20 s

Because the write went to the store while the loader's memo entry for that key stayed as it was. Nothing invalidates it, so any later load in the same request returns the pre-write value. Evict the key after writing, or replace its entry.

solid answer

~50 s

A loader memoizes an entry the first time a key is loaded and never revisits it. If the mutation's own read populated that entry — or a sibling field did — then writing to the store afterwards leaves the map holding the old row, and the payload field that reads the updated object gets the stale one. The fix is to invalidate at the point of the write: after the store commits, clear that key from the loader, or prime it with the value you already have. Note that in the common implementations priming does not overwrite an existing entry, so seeding a changed key means clearing first and then priming. Prefer clearing exactly the keys the write touched over clearing everything, which throws away the deduplication the rest of the request depends on, and over disabling memoization, which turns one correctness bug into an N+1.

code

pseudocode · 15 lines
pseudocode
resolve Mutation.reassignVehicle(root, args, ctx):
    vehicle = ctx.loaders.vehicles.load(args.vehicleId)   // memoizes the OLD row
    require(vehicle.activeTripId == null)

    updated = store.setVehicleDepot(args.vehicleId, args.depotId)   // commits

    // the memo entry still holds the pre-write row: evict it
    ctx.loaders.vehicles.clear(args.vehicleId)
    ctx.loaders.vehicles.prime(args.vehicleId, updated)   // no-op unless cleared first

    // the write also changed both depots' membership
    ctx.loaders.depots.clear(vehicle.depotId)
    ctx.loaders.depots.clear(args.depotId)

    return { vehicleId: args.vehicleId }

go deeper

for a junior

Remember that writing to the store does not update anything a loader already remembered, and that the loader offers a way to forget a key so the next read goes back to the store.

for a middle

Explain the sequence that produces it — an early read memoizes, the write commits, a later field hits the entry — and the difference between clearing a key and priming it with a known value.

for a senior

Show judgement about placement and blast radius: invalidate beside the write so every caller inherits it, clear the related keys rather than only the written row, and reject a whole-map clear or a disabled memo map as fixes.

for a principal

Own the read-after-write consistency story across the service: which layers may hold a value within one request, who is accountable for invalidating each, and how that policy is enforced rather than remembered.

## The map does not watch the store A loader's memo entry is written once and never checked again. It has no connection to the store it came from: no change feed, no version check, no expiry. So the moment a request both reads and writes the same entity, the two views can diverge, and the loader's view is the one the rest of the request sees. The sequence is nearly always the same: 1. something early in the request loads key `k` — often the mutation resolver itself, reading the row to validate the change, sometimes an unrelated sibling field; 2. the mutation writes to the store and commits; 3. a field in the mutation payload, or a later root field, loads `k` again; 4. step 3 hits the memoized entry from step 1 and returns the pre-write row. The response is internally inconsistent: it reports success and shows the old value. That is worse than an error, because clients trust it. ## A worked example on a fleet telematics graph A mutation reassigns a vehicle to a different depot. Its resolver loads `vehicle-4417` to check the vehicle is not mid-trip, writes the new depot id, and returns a payload with a `vehicle` field. That payload field resolves through the same loader, hits the entry populated during validation, and reports the old depot. The operator's screen shows "reassigned" next to the depot they reassigned *from*. A refresh — a new request, a new loader — shows it correctly, which is exactly why this survives review and reaches production: it is invisible to anyone who reloads. ## Clearing versus priming Two operations are conventionally available on a loader, and they answer different questions. **Clear the key.** Remove the entry so the next `load` re-fetches. Correct by construction, and the right default: it makes no claim about what the new value is, and it survives a write that changed more than you passed in — a trigger, a computed column, a server-set timestamp. **Prime the key.** Insert a value you already hold, so the next `load` returns it without a round trip. Cheaper, and appropriate when the write returned the authoritative post-write row. The trap, and it is a frequent interview follow-up: in the common implementations priming is a no-op when the key is already present, precisely so a prime cannot silently clobber an in-flight or already-resolved entry. Seeding a key that has been loaded therefore means clearing it and then priming. Whether your implementation behaves that way is worth confirming rather than assuming, since none of this is specified. A third habit is worth naming so it can be rejected: clearing the whole map after any write. It works, and it throws away every unrelated memoized key at the same time, so the rest of the request re-fetches things nothing changed. On a document that walks a wide graph after a small write, that can be the difference between one batched call and dozens. ## Where the invalidation belongs Put the clear next to the write, not in the field that noticed the problem. If the write goes through one function that owns the store call, that function clears the affected keys immediately after it commits; every mutation that uses it inherits correct behaviour, including the ones written next year. Scatter the clears through payload resolvers instead and you have a rule someone must remember at every call site, which is the same shape of defect as remembering to check a caller's identity per field. Also clear the *keys the write touched*, not only the key you wrote. Reassigning a vehicle changes the vehicle row and both depots' membership, so a loader keyed by depot is stale too. Working out that set is a modelling exercise, and getting it wrong is how a partial fix passes review. ## Ordering, and what actually protects a later root field One piece of this *is* specified. The GraphQL specification requires the top-level selection set of a mutation to execute serially — each root field completes before the next begins — while fields of a query selection set may be executed in any order. That serial rule is what makes clearing between root fields dependable: if the first root field's resolver clears a key after committing, the second root field's reads are guaranteed to happen afterwards. Inside a single field's own sub-selection there is no such guarantee, so a payload field and its siblings must not be relied on to run in any particular order. Design the invalidation to happen before the payload is returned, not to win a race inside it. ## How this shows up and how to test it It reproduces only when a read and a write share a request, so a test that mutates in one request and asserts in another will never see it. The test that catches it executes one document — the mutation plus a payload selection that reads the changed field — against a real loader set and asserts the post-write value. Cheap to write, and it fails loudly the day someone adds a validation read to a resolver that previously did not have one. ## What a weak answer looks like Disabling memoization for the whole request "because mutations are involved". That does fix the symptom, and it reinstates the N+1 the loader was introduced to remove — trading a rare wrong value for a permanent load-shaped cost. The strong answer keeps memoization and puts eviction where the write is.

  • Why prefer clearing one key over clearing the whole loader after a write?
    Because clearing everything discards memoized keys the write never touched, and the rest of the request re-fetches them. On a document that walks a wide graph after a small write that can turn one batched call into many. Clearing the affected keys keeps the deduplication the request still depends on, at the cost of having to know which keys the write actually changed.
  • Which keys does a write invalidate, beyond the row you wrote?
    Every key whose loaded value the write could change. Reassigning a vehicle changes the vehicle row and the membership of both the old and new depot, so loaders keyed by depot are stale as well. Working out that set is modelling work, and a fix that clears only the obvious key is the usual partial fix that passes review and fails in production.
  • Does GraphQL guarantee an ordering that makes clearing between fields reliable?
    Partly. The specification requires the top-level selection set of a mutation to execute serially, so a key cleared by the first root field is genuinely re-fetched by the second. Fields of a query selection set carry no such guarantee, and nor do siblings inside one field's sub-selection. So invalidate before returning the payload rather than relying on resolution order within it.

saying these in an interview costs you the question

  • Disables memoization for the whole request
  • Assumes the loader notices a committed write
  • Clears the entire map after every mutation
  • Primes a key without clearing it first
  • Clears only the written row, not related keys
  • Tests the mutation and the read in separate requests

context