What does a GraphQL mutation that returns only `Boolean` force every caller to do?
answer
- The caller is left holding nothing
- A second request that may lag
- Return what the caller re-renders
- Server-computed values only the write knows
- Deletes return the identifier
basics
~20 sIt forces a second round trip. The caller holds no updated record, so it must refetch — and that follow-up read is a separate request that can be served stale, showing pre-mutation values the caller believes are fresh.
solid answer
~50 sA `Boolean` payload says the write was accepted and nothing more: not the new field values, not anything the server computed such as a timestamp, a derived status or a generated identifier, and not the identity of what changed. Every caller therefore issues a follow-up query. That costs a round trip, and it is not guaranteed to observe the write — a read served by a replica or by a warm response cache can return the pre-mutation state, and the caller cannot tell the difference. The fix is a payload object carrying the mutated entity with the fields the caller will re-render, including whatever the server decided rather than merely stored. A rich payload costs nothing to callers that do not select those fields, because the client chooses the selection set. None of this is specified; it is convention plus consequence.
code
graphql · 3 linestype Mutation {
updateListingPrice(listingId: ID!, askingPrice: Int!): Boolean!
}go deeper
Remember the basic consequence: a mutation returning only Boolean leaves the caller with nothing to display, so it has to ask again. Returning the changed record avoids that second request.
Explain what belongs in a payload and why — the entity's identifier, the changed fields, and anything the server computed such as a generated id or a timestamp, which the client could never work out for itself.
Name the consistency hazard, not just the round trip: the follow-up read is a separate request on a possibly different read path, so it can return pre-mutation state, and the symptom looks like a client rendering bug.
Set the standard and its limits. Decide what a payload is allowed to reach for before a mutation becomes a query in disguise, and how read-after-write expectations are stated so teams stop encoding them by accident.
## The incident A real-estate listings graph exposed a price edit like this: ```graphql type Mutation { updateListingPrice(listingId: ID!, askingPrice: Int!): Boolean! } ``` The agent console had no choice about what to do next. Having received `true`, it knew a write had happened somewhere and knew nothing about the result, so after every single-field edit it refetched the agent's whole listing page — an 8,400-row result — and re-sorted client-side by `lastPricedAt`, a timestamp the server sets when a price changes. Two things were wrong, and only one of them was obvious. The obvious one was cost: a full page refetch after changing one integer. The expensive one was an ordering assumption. Reads were served from a read replica; the mutation wrote to the primary. The refetch, issued a few tens of milliseconds after the mutation returned, sometimes landed before replication caught up and came back with the *old* `lastPricedAt`. The edited listing did not move to the top of the list. The team's working assumption — that a refetch issued after a mutation returns observes that mutation's write — is nowhere guaranteed, and it broke intermittently under load. Because the response was well-formed and plausible, it was triaged as a client-side sorting bug for weeks before anyone looked at where the read was served from. ## Why the payload fixes it and the refetch cannot The mutation resolver is the one piece of code in the system that certainly knows the post-write state. It performed the write, it read its own write from the primary, and it holds the values the server computed. When it returns those values in a payload, the caller receives them in the same response as the acknowledgement: ```graphql type UpdateListingPricePayload { listing: Listing } ``` There is no second request, so there is no window in which a different read path can disagree. The console patches the row it already has rather than re-downloading 8,400 of them. ## What a payload should carry **The mutated entity, with its identity.** The identifier matters as much as the changed values — without it the caller cannot tell which of the rows it is holding the response refers to. **Everything the server decided rather than stored.** Generated identifiers and reference numbers, timestamps, derived status, normalized or rounded values, defaults the server filled in. These are the fields a client can never compute for itself, and they are exactly the ones a thin payload hides. **Other roots the action genuinely changed.** If publishing a listing also increments an agent's active-listing count, the payload can expose the agent so the caller can select it. Note the cost: resolving those fields is real server work, and a payload that reaches too far turns every mutation into a query. Expose them; do not force them. What the payload cannot repair — which collections the record now belongs to, and what a normalized client cache does with the entity it receives — is a separate topic with its own answers. ## Deletes are the interesting case A delete has no entity left to return, which is why deletes are where thin payloads are most tempting. The convention is to return the deleted record's identifier, and often the parent that lost it: ```graphql type DeleteListingPayload { deletedListingId: ID agent: Agent } ``` The identifier is what lets the caller act at all — it names precisely what to remove from whatever it is holding. `Boolean!` gives it a fact with no subject. ## The other problem with a Boolean `Boolean!` conflates three different statements: the request was accepted, the write succeeded, and something actually changed. An idempotent re-run that finds the record already at the requested price returns `true` having done nothing, and the caller cannot distinguish that from a real change. A conditional update that matched no rows returns... whichever of `true` or `false` the author happened to pick, and no client can know which. And a scalar return type is a dead end for evolution. Changing `Boolean!` to `UpdateListingPricePayload!` changes the shape of every existing response and every document that selects it. That is the same argument that motivates the payload convention in the first place, arriving from the failure end instead of the design end. ## Why a richer payload is close to free The reflex objection is that returning the entity makes every mutation response bigger. It does not, because GraphQL clients choose their own selection sets. A caller that wants only the acknowledgement selects one cheap field and receives one cheap field; a caller that wants to re-render selects six. The server pays only for what was asked for. The cost that *is* real is resolver work behind expensive payload fields — an aggregate count on a parent, say — and the answer there is the ordinary one: make it available, let callers opt in, and measure it like any other field. ## What an interviewer is listening for The weak answer stops at "it saves a round trip". The strong answer names the consistency hazard: the follow-up read is a *separate request*, subject to a different read path, and nothing in GraphQL or in HTTP promises it observes the write that preceded it. Then it names what specifically belongs in the payload — identity, server-computed values, genuinely affected neighbours — and concedes the one real cost, which is resolver work rather than response size.
- Does returning the mutated entity make every mutation response more expensive?Not on the wire — the caller chooses its selection set, so a client wanting only the acknowledgement selects one field and pays for one field. The real cost is resolver work behind expensive payload fields, such as an aggregate on a parent object. Expose those fields so callers can opt in, and measure them like any other field rather than forcing them into every response.
- What should a delete mutation return, given the record no longer exists?The identifier of the deleted record, so the caller knows precisely what to drop from whatever it is holding, and usually the parent that lost it if the caller renders a count or a list header. Returning `Boolean!` gives the caller a fact with no subject; returning the deleted entity's full field values is both awkward to resolve and rarely what anyone needs.
- Why is `Boolean!` a poor acknowledgement even ignoring the extra round trip?It conflates three claims: accepted, succeeded, and changed something. An idempotent re-run against a record already in the requested state returns `true` having done nothing, and the caller cannot tell that from a real change. It is also a dead end for evolution — replacing a scalar return type with a payload type changes the shape of every existing response.
A Boolean is a delivery receipt with no tracking number. It tells you something arrived; it does not tell you what state the parcel is in, and going to look for yourself may catch the shelf before it has been restocked.
saying these in an interview costs you the question
- Returns Boolean and says the client can just refetch
- Assumes a refetch after a mutation always observes the write
- Thinks a bigger payload costs callers that did not select it
- Returns an entire parent collection from a single-record mutation
- Says a delete mutation has nothing useful to return
- Treats `true` as proof the record actually changed