The One Ordering Guarantee the Spec Hands You
The One Ordering Guarantee the Spec Hands You
Query root fields may execute in whatever order the server finds efficient, because reads are independent of each other. Mutations get the opposite rule: the spec requires the top-level fields of a mutation operation to execute serially, in the order the client wrote them — each field's work completes before the next begins.
mutation {
a: bumpCounter # 1. runs to completion
b: bumpCounter # 2. starts only after 1 finishes
c: sendWelcomeEmail # 3. starts only after 2 finishes
}
document order: a ────────► b ────────► c ────────►
wall clock: [== a ==] [== b ==] [== c ==]
never a, b, c overlapping
Why the asymmetry? Writes are observable. If three root fields each read a balance, add 10, and write it back, parallel execution produces lost updates. Serial root execution gives a sequence where later fields observe earlier effects — provided each resolver awaits its own write before returning.
Two properties of the guarantee matter:
- Aliases expose the order. The client (or you, in testing) can read the intended sequence straight from the document, since root field order in the document is execution order.
- It applies to mutation operations only. The same fields declared on
Queryget no such promise; readability and side effects are the entire distinction between the two roots.
Contrast that with everything inside a root field: subfield selection follows ordinary query execution and may fan out concurrently, which is covered in async resolver patterns.
Where the Guarantee Stops
Where the Guarantee Stops
Serial execution has three edges, and each has caused a production incident.
Only root fields are covered. Subfields under one mutation root field are not required to run one at a time. If checkout { order { id } payment { receipt } } reaches two downstream services, those two reads may execute concurrently even though checkout itself ran in sequence with its siblings.
Only within one document. Two clients posting separate operations interleave freely:
client A: [ createOrder A1 ][ charge A2 ]
client B: [ createOrder B1 ][ charge B2 ]
Nothing orders A1 against B1. Cross-request ordering is the job of database transactions, row locks, or a queue — never field order.
Only as strong as the promises the resolvers return. The executor waits for what a resolver hands back. A root-field resolver that starts a write and returns without awaiting releases the next root field before the first write lands:
Mutation: {
// WRONG: returns before the insert commits
createOrder: (_p, args, ctx) => { ctx.orders.insert(args); return {}; },
chargeCard: (_p, args, ctx) => ctx.payments.charge(args), // may start too early
}
Every root-field resolver must await its side effects, or the guarantee becomes a lie the schema appears to tell.
Dependent writes should not rely on field order at all. Even with correct awaiting, a client may split the work across documents, or a proxy may replay one of them. Pass values forward through the payload instead of re-reading shared state:
mutation {
transfer(from: "acct-a", to: "acct-b", amount: 4000) {
fromBalance
toBalance
transferId
}
}
The next step consumes what the transfer returned. That works regardless of which server handles which document, and it survives retries — the pattern in mutation return payloads is what makes it expressible.
Idempotency: The Order Problem You Actually Have
Idempotency: The Order Problem You Actually Have
Serial execution protects writes inside one document. The failure that pages you comes from outside: a client times out after the server committed, retries, and a second order exists. Ordering worked perfectly; intent was not preserved.
The standard fix is a client-generated idempotency key with a uniqueness constraint behind it:
async createOrder(_p, args, ctx) {
const seen = await ctx.dataSources.orders.byIdempotencyKey(args.key);
if (seen) return seen.toPayload(args.key); // replay: same answer, no second write
try {
return await ctx.dataSources.orders.transaction(async (tx) => {
const order = await tx.insertOrder(args); // UNIQUE(idempotency_key) closes the race
await tx.reserveStock(order.id);
return order.toPayload(args.key);
});
} catch (err) {
if (err.code === 'unique_violation') { // two attempts raced each other
return (await ctx.dataSources.orders.byIdempotencyKey(args.key)).toPayload(args.key);
}
throw err;
}
}
Timeline under retries:
attempt 1: key=k7 → no record → insert + reserve → 200 { order }
attempt 2: key=k7 → record found → return stored order (no insert, no charge)
attempt 3: key=k7 → races attempt 2 → unique violation → re-read → return stored order
Design notes:
- Generate the key per logical action, not per HTTP attempt — a retry must reuse it, or nothing dedupes.
- Persist the key with enough state to rebuild the response, so replays answer identically.
- Rely on the unique constraint rather than a read-then-insert sequence; the latter has a race window exactly when traffic spikes.
- Carry the key in an input field or a header;
clientMutationIdworks for this role only if the server stores it.
Interview angle: when asked "what happens if the same mutation arrives twice?", cover three layers — root fields are ordered within a request, transactions isolate between requests, and idempotency keys make retries harmless. Candidates who answer only "mutations run serially" have described an ordering property, not duplicate protection.
Practice Problems
A createOrder mutation is retried by clients after timeouts and ends up creating duplicate orders. Rewrite the resolver so the same client-generated key never produces two orders, even when two retries arrive concurrently, and so a replay returns the original result instead of an error.
Example:
Input: createOrder(input: { items: [...], key: "k7" }) — sent twice
Output: both calls return { order: { id: "o-1" }, clientMutationId: "k7" } and exactly one row exists
The second call finds the persisted key and replays the stored order; no second insert and no second stock reservation.
Optimal Solution — O(1) time, O(1) space
Persist the key with a unique constraint, check before inserting, and treat a unique violation as a replay instead of a failure.
type CreateOrderInput {
items: [OrderItemInput!]!
key: String! # idempotency key, one per logical action
}
async createOrder(_p, { input }, ctx) {
const existing = await ctx.orders.byIdempotencyKey(input.key);
if (existing) return existing.toPayload(input.key);
try {
return await ctx.orders.transaction(async (tx) => {
const order = await tx.insertOrder(input); // UNIQUE(idempotency_key)
await tx.reserveStock(order.id);
return order.toPayload(input.key);
});
} catch (err) {
if (err.code !== 'unique_violation') throw err;
const winner = await ctx.orders.byIdempotencyKey(input.key);
return winner.toPayload(input.key); // concurrent retry: replay the winner
}
} Edge Cases:
- Missing key must fail validation rather than silently generating a random one per attempt
- Replay after the original response was lost must return the fully built payload, not just an id
- A unique violation on an unrelated column must not be treated as a replay
Quiz
1. For mutation { a b c } where every root field is a write, what order do the resolvers run in?
2. Two clients send mutation operations at the same moment in separate HTTP requests. What ordering is guaranteed?
3. A root-field resolver starts a database insert and returns without awaiting it. What happens to the serial guarantee?
4. A mobile client times out after the server already committed createOrder, then retries. What actually prevents a duplicate order?
Flashcards
Question
Serial execution rule
Click to reveal answer
Answer
Mutation root fields execute one at a time, in document order — each completes before the next begins. Query roots have no such promise.
Question
Scope of the serial guarantee
Click to reveal answer
Answer
Root fields of one operation only. Subfields may run in parallel, and separate HTTP requests interleave freely.
Question
Why dependent writes use payload values
Click to reveal answer
Answer
Chaining through returned fields (fromBalance, transferId) works across retries, splits, and servers; assuming field order only works inside one correctly awaited document.
Question
Idempotency key rules
Click to reveal answer
Answer
Generated per logical action (reused on retries), persisted with the write, enforced by a UNIQUE constraint — not a read-then-insert check.
Question
clientMutationId vs idempotency key
Click to reveal answer
Answer
The id matches responses to requests; duplicate protection happens only if the server stores that id and treats a repeat as a replay.
Revision Notes
Key Takeaways
- 1. Serial execution is an ordering property inside one mutation document, not duplicate protection
- 2. The executor only waits on returned promises — unawaited writes break the guarantee silently
- 3. Chain dependent steps through payload fields so correctness never depends on scheduling
- 4. Retries need a persisted idempotency key plus a unique constraint; application-level find-then-insert leaves a race
Interview Tips
- • Answer in layers when ordering comes up: within one operation (serial), across requests (transactions/locks), across retries (idempotency keys)
- • Be ready to write the replay branch of an idempotent resolver and explain why the unique constraint, not the lookup, is the real defense
Cheat Sheet
Serial Execution Cheat Sheet
mutation { # root fields run 1 → 2 → 3, never overlapped
createOrder { id }
chargeCard(orderId: "o1") { status }
sendReceipt(orderId: "o1") { sentAt }
}
- Serial = root fields of ONE operation, in document order
- Not serial: subfields under a root field; separate HTTP requests; unawaited writes
- Awaiting is part of the contract:
return db.insert(...), neverdb.insert(...); return - Dependent data flows through payload fields, not through assumed order
- Idempotency: client key per logical action →
UNIQUE(idempotency_key)→ replay stored response - Race handling: catch the unique violation and re-read rather than trusting find-then-insert