Skip to content
advanced Phase 3 · Mutations, Arguments, and Input Types

Serial Execution and Write Ordering

Why mutation root fields run in order and how that affects dependent writes.

40m
1 problems
Topic Progress 0%

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 Query get 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; clientMutationId works 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

0 / 1 solved
Make createOrder Idempotent Under Retries
Idempotent Writes

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?

Question 1 options

2. Two clients send mutation operations at the same moment in separate HTTP requests. What ordering is guaranteed?

Question 2 options

3. A root-field resolver starts a database insert and returns without awaiting it. What happens to the serial guarantee?

Question 3 options

4. A mobile client times out after the server already committed createOrder, then retries. What actually prevents a duplicate order?

Question 4 options

Flashcards

Question

Serial execution rule

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

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

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

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

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(...), never db.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

Continue Learning