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.
Idempotency: safe to retry
Section titled “Idempotency: safe to retry”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.
Two kinds of errors
Section titled “Two kinds of errors”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-levelerrorsarray stays empty. - Top-level
errors— protocol 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-levelerrorsarray, and the affected field’sdataisnull.
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 []"] 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.
Avoid chatty mutations
Section titled “Avoid chatty mutations”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
idforces aGETto 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.
Optimistic UI considerations
Section titled “Optimistic UI considerations”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
idis what closes that loop. - Surface
userErrorscleanly, 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.
Putting it together, run for real
Section titled “Putting it together, run for real”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.
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.