Skip to content

Mutation Best Practices

You now know how to name a mutation, shape its input and payload, and notify the world with a subscription. This final lesson is the polish: the handful of habits that separate a mutation that works in a demo from one that survives flaky networks, retries, and an impatient UI. Four topics — idempotency, the two kinds of errors, chattiness, and optimistic UI.

Networks drop responses. A client fires addReview, never sees the reply, and retries — now you have two reviews. A mutation is idempotent when running it twice has the same effect as running it once. The usual technique is a client-supplied idempotency key: the client generates a unique token per intent and sends it with the mutation; the server records keys it has seen and short-circuits duplicates.

input AddToCartInput {
productId: ID!
quantity: Int!
requestId: ID! # client-generated key — the same retry carries the same id
}

Not every mutation needs a key. Setting published: true is naturally idempotent — running it twice lands on the same state. It is the create and increment style mutations, where a duplicate produces a duplicate effect, that benefit most.

This is the line the input/payload lesson promised to draw. GraphQL gives you two places to report failure, and they mean different things:

  • User errors in the payload — expected, recoverable, per-field problems the user can fix: a rating out of range, a name already taken, a coupon expired. These are data. They ride in the payload’s userErrors, the request is otherwise a success, and the top-level errors array stays empty.
  • Top-level errorsprotocol and unexpected failures: a malformed query, a failed authorization check, a database that fell over. These are not something the end user can correct by editing a form. They populate the response’s top-level errors array, and the affected field’s data is null.
flowchart TD
  Req["Mutation request"] --> Kind{"what failed?"}
  Kind -->|"user can fix it"| Pay["payload.userErrors (data ok)"]
  Kind -->|"protocol / unexpected"| Top["top-level errors (data null)"]
  Kind -->|"nothing"| OK["payload.entity, userErrors []"]
Recoverable validation goes in the payload; protocol and unexpected failures go in the top-level errors array.

The reason to keep them apart is the client. Code that renders a validation message under a form field should never have to parse the same channel that surfaces “your session expired.” Put what the user can fix in userErrors, and let everything else throw.

A chatty design forces the client to make many calls to accomplish one user goal — add item, add item, apply coupon, set address — each a separate round trip. Two fixes:

  • Batch genuinely-atomic work into one mutation. If three changes must all succeed or all fail together, that is one logical change, so model it as one mutation taking a richer input. (Recall the previous lesson’s rule: do not bundle unrelated intents — but truly-atomic work belongs together.)
  • Return enough data to avoid follow-up reads. A mutation that returns only an id forces a GET to learn what changed. Return the affected object — and the parent it belongs to if the UI needs it — so one round trip both writes and refreshes.

A responsive client often updates the screen before the server confirms — it assumes the mutation will succeed, renders the result immediately, and reconciles when the real payload arrives. Your schema design decides how smooth that is:

  • Return the full affected object, including server-assigned fields, so the optimistic guess can be replaced by truth without a re-fetch.
  • Make ids predictable enough to reconcile. The client needs to match its temporary optimistic record to the server’s real one when the payload lands — returning the canonical id is what closes that loop.
  • Surface userErrors cleanly, because optimistic UI must be able to roll back. If the payload comes back with a validation error, the client undoes its optimistic change and shows the message — which only works if errors are predictable data in the payload, not exceptions in the top-level array.

The example shows idempotency and the two error channels in one resolver. Press Run: a duplicate requestId returns the same review instead of creating a second; an out-of-range rating returns a userError with review: null; everything else throwing would populate the top-level errors array.

JavaScript

The final line is the proof: after a create, a retry, and a rejected call, the store holds exactly one review. The retry returned the original review unchanged — idempotency in action — and the invalid call came back as a userError in the payload with review: null, never touching the store and never raising a top-level error. That is every best practice from this lesson visible in one output.

What makes a mutation idempotent, and how is it commonly achieved?
A user submits a rating of 9 when the allowed range is 1–5. Where should this be reported?
Why does returning the full affected object help an optimistic UI?