The Boolean That Costs a Second Request
The Boolean That Costs a Second Request
Here is a mutation that works and still wastes a round trip:
type Mutation {
createComment(body: String!, postId: ID!): Boolean!
}
The client sends it, receives true, then issues post(id: $postId) { comments { ... } } to render the comment it just wrote — on the slowest moment of the whole session, right after the user pressed submit. Two requests where one would do, plus a window where the UI displays stale data.
Return the data instead:
input CreateCommentInput {
body: String!
postId: ID!
clientMutationId: String
}
type CreateCommentPayload {
comment: Comment!
post: Post! # the post whose comment count just changed
clientMutationId: String
}
type Mutation {
createComment(input: CreateCommentInput!): CreateCommentPayload!
}
And the document that uses it:
mutation CreateComment($input: CreateCommentInput!) {
createComment(input: $input) {
comment { id body author { name } createdAt }
post { commentCount }
clientMutationId
}
}
One request, and the response holds every value the screen touches: a normalized cache inserts Comment:<new id> and patches the post's counter without refetching.
The payload is not limited to the row you wrote. Include whatever the mutation invalidated — the parent whose counter moved, the viewer's new credit balance, the summary the sidebar re-renders. Because callers select their own fields, a broader payload costs nothing to clients that never ask for it.
A pattern that earns its keep across most write APIs:
type ArchiveOrderPayload {
order: Order! # the record itself, in its new state
viewer: Viewer! # aggregates the write invalidated
userErrors: [UserError!]!
clientMutationId: String
}
viewer (some schemas call it me) carries the caller's updated totals: unread count, open orders, remaining credit. Those are exactly the values every other screen renders seconds after a write, and returning them once beats each screen issuing its own refetch.
One thing a payload must never do is hide a failure. If the write did not happen, do not return a stale order alongside an empty userErrors array — either the entity in the payload reflects reality, or the payload says precisely why it does not. Clients branch on that distinction to decide whether to show the new state or the recovery form.
Correlation Tokens and Payload Naming
Correlation Tokens and Payload Naming
clientMutationId is a caller-generated string the server echoes back unchanged. It reads like ceremony until you trace a real flow: a mobile app queues writes while offline, flushes them on reconnect, and receives responses out of order. The id is how the app matches each response to the request that caused it.
mutation {
createComment(input: { body: "ship it", postId: "p-1", clientMutationId: "m-8f3a" }) {
clientMutationId # returns "m-8f3a"
comment { id }
}
}
It also supports retries: the client mints one id per logical action, so every retry carries the same id and the server can recognize a repeat instead of writing twice. Keep the division of labor straight — the token enables correlation, while duplicate effects are prevented only if the server persists that token and short-circuits on a match. Correlation by itself does not make a mutation safe to retry; the write-side mechanics are in serial execution.
Naming follows MutationNamePayload: CreateCommentPayload, ArchiveOrderPayload, BulkImportPayload. Generated clients then get one predictable type per mutation, and payloads stay greppable in code review. Even a mutation that changes nothing worth showing benefits:
type DeleteCommentPayload {
deletedId: ID!
commentCount: Int! # so the list can re-render immediately
}
success: Boolean alone answers nothing a client can act on — deletedId says what was removed and the count says what to show next.
Payloads are also the natural home for structured, caller-fixable failures, which the next chapter separates from failing the whole operation.
Field Errors in the Payload Versus Failing Everything
Field Errors in the Payload Versus Failing Everything
A checkout mutation receives three line items, one carrying a stale price. Two designs, both legal GraphQL.
Design A — throw. The resolver raises on the first bad item. The response is data: { checkout: null } plus an entry in errors. The client learns that something failed and must parse a message string to learn which item, while any work already committed may or may not have happened.
Design B — return both. The mutation validates up front, writes what it decided it may write, and returns structured failures next to the result:
type CheckoutPayload {
order: Order
userErrors: [UserError!]!
}
type UserError {
field: [String!]! # ["input", "items", "2", "price"]
message: String!
}
{
"data": {
"checkout": {
"order": null,
"userErrors": [
{
"field": ["input", "items", "2", "price"],
"message": "Price changed since you added this item"
}
]
}
}
}
The field path points at the exact input, so a form can highlight the offending row instead of matching on message text.
How to choose:
| Situation | Where it goes |
|---|---|
| Caller-fixable validation (bad format, stale price) | payload userErrors — the operation itself succeeded |
| Authentication, authorization, not-found | top-level errors — there is no meaningful payload |
| Infrastructure failure (database down) | top-level errors with a retryable code in extensions |
HTTP status stays 200 in all three cases; the distinction is which structure the client reads, not the transport. The top-level shape is covered in standard error shape and the bubbling mechanics in resolver error propagation.
Whatever you choose, apply it consistently per mutation. When one mutation throws for validation and the next returns userErrors, every client must implement both paths for every write — the worst of both worlds.
Practice Problems
A checkout mutation takes a list of items and a shipping address. One item may have a stale price and the address may fail validation; the client needs to know exactly which inputs were rejected while still receiving the order when everything passes. Declare the payload, show the response for a mixed outcome, and say what happens when validation fails before anything is written.
Example:
Input: mutation { checkout(input: { items: [...], address: {...}, clientMutationId: "c-77" }) { order { id totalCents } userErrors { field message } clientMutationId } }
Output: { "data": { "checkout": { "order": null, "userErrors": [ { "field": ["input","items","1","priceCents"], "message": "Price changed: expected 4500, got 4900" } ], "clientMutationId": "c-77" } } }
The field path names the exact item, the client can refresh that row, and the echoed id lets the app pair this failure with the queued request that caused it.
Optimal Solution — O(n) time, O(n) space
Validate everything first, write nothing on failure, and return one payload type carrying the nullable order, structured field errors, and the correlation token.
type CheckoutPayload {
order: Order
userErrors: [UserError!]!
clientMutationId: String
}
type UserError {
field: [String!]!
message: String!
}
type Mutation {
checkout(input: CheckoutInput!): CheckoutPayload!
}
const resolvers = {
Mutation: {
checkout: async (_p, { input }, ctx) => {
const errors = validateCheckout(input); // no writes yet
if (errors.length) {
return { order: null, userErrors: errors, clientMutationId: input.clientMutationId };
}
const order = await ctx.dataSources.orders.create(input);
return { order, userErrors: [], clientMutationId: input.clientMutationId };
},
},
}; Edge Cases:
- Empty items array must be rejected by validation, not by a database constraint
- A validation failure and a database failure are different: the second is a top-level error with a retryable code
- clientMutationId omitted — payload must still build with null rather than crashing
Quiz
1. A mutation returns Boolean! and the UI must display the updated record. What must the client do?
2. What is clientMutationId for?
3. A line-item price is wrong in a checkout mutation. Where should that failure be reported?
4. Which naming convention keeps generated clients predictable?
Flashcards
Question
Why mutations return entities instead of booleans
Click to reveal answer
Answer
The client renders and patches its cache from the mutation response instead of refetching — one round trip instead of two.
Question
clientMutationId
Click to reveal answer
Answer
Caller-generated token echoed by the server so responses can be matched to requests under queuing, retries, or out-of-order delivery. Correlation only — dedup requires server storage.
Question
userErrors vs top-level errors
Click to reveal answer
Answer
userErrors: caller-fixable validation, returned in data with a field path, operation succeeded at the protocol level. Top-level errors: auth, not-found, infrastructure faults.
Question
MutationNamePayload convention
Click to reveal answer
Answer
CreateCommentPayload, ArchiveOrderPayload — one payload type per mutation so codegen and code review stay predictable.
Question
What else a payload should carry
Click to reveal answer
Answer
Anything the mutation invalidated: parent counters, viewer balances, list summaries — clients select only what they render.
Revision Notes
Key Takeaways
- 1. Returning only a boolean forces a refetch at exactly the moment the UI has stale data
- 2. Payloads carry the changed entity plus everything it invalidated; selection sets keep that cheap for clients
- 3. clientMutationId correlates responses; idempotency keys prevent duplicate writes — different jobs, both needed
- 4. Caller-fixable failures belong in structured payload errors; auth and infrastructure failures stay top-level
Interview Tips
- • Design a mutation payload on the whiteboard: entity, clientMutationId, userErrors with field paths — then say what you deliberately left out
- • Be ready to defend consistency: an interviewer will push on "what if one mutation throws and another returns userErrors?" — answer that clients now need both code paths
Cheat Sheet
Mutation Payload Cheat Sheet
input CreateCommentInput { body: String! postId: ID! clientMutationId: String }
type CreateCommentPayload {
comment: Comment!
post: Post!
userErrors: [UserError!]!
clientMutationId: String
}
type UserError { field: [String!]! message: String! }
- Return the changed entity (+ invalidated parents/balances) so clients skip the refetch
- Name payloads
MutationNamePayloadfor stable codegen clientMutationId= correlation echo; duplicate-write protection needs a persisted key- userErrors (field path) for caller-fixable input; top-level errors for auth/infra
- HTTP stays 200 either way — clients read
dataanderrors, not status codes - Pick one failure style per mutation and keep it