REST vs GraphQL: who decides the shape of the response
You asked for a name and a price and thirty-eight fields came back. Every difference between the two is one trade seen from a different side — and the caching bill is where it comes due.
You asked for a name and a price. Thirty-eight fields came back, including a
created_by object with its own address. On the next screen you needed one extra
field that /products/42 doesn’t carry, so you made a second request, and then a
third for the reviews.
Both of those are the same fact from opposite sides, and it is the only fact this comparison is about:
Who decides the shape of the response — the server, or the client?
REST says the server, and gets HTTP caching almost free because the address is the cache key. GraphQL says the client, and pays for that in caching, in where errors live, and in what a client is allowed to make the server do. This is the written half of a ninety-second video, and it covers what ninety seconds could not: the fix for the caching bill, the resolver version of a bug from episode 12, and the status-code question the video deliberately refused to answer.
The trade, stated by the spec that benefits from it
GraphQL’s own specification frames the contrast without needing anyone to editorialise. From §1, Overview: “In the majority of client-server applications written without GraphQL, the service determines the shape of data returned from its various endpoints. A GraphQL response, on the other hand, contains exactly what a client asks for and no more.”
And the mechanism, from the same section: “It is the client that is responsible for specifying exactly how it will consume those published capabilities. These requests are specified at field-level granularity.”
Over-fetching and under-fetching are not two problems. They are one — the server picked the shape and it wasn’t yours — and field-level client specification is the direct answer to it. That much is not in dispute by anybody. The interesting part is the bill.
The caching bill, and how to actually pay it
RFC 9111 §2 defines what a cache is keyed on: “The ‘cache key’ is the information a cache uses to choose a response and is composed from, at a minimum, the request method and target URI used to retrieve the stored response… However, many HTTP caches in common use today only cache GET responses and therefore only use the URI as the cache key.”
That sentence is REST’s whole caching story. GET /products/42 is a key. A CDN that
knows nothing about your application can store the answer, serve it to the next
person, and never speak to your origin. The address being the identity of the thing
is what makes it work.
Now send every GraphQL query as POST /graphql. One method, one URI, one key — and
the thing that distinguishes two requests is in a body the cache does not read. A
shared cache cannot tell them apart, and correctly refuses to try.
The mitigation the video had one line for is persisted queries, and it is worth
the space because it recovers most of what was lost. The client registers the query
text ahead of time, gets back a hash, and sends the hash instead of the document.
Once a request is a short, stable identifier, it fits in a URL — and the
GraphQL-over-HTTP specification defines exactly how a GET carries one: the
parameters “MUST be provided in the query component of the request URL, encoded in
the application/x-www-form-urlencoded format,” with query, operationName,
variables and extensions as the parameter names.
So GET /graphql?extensions=…hash… is a cache key again, and a CDN can do its job.
Be precise about provenance, though: the GET transport is specified, while the
persisted-query handshake itself — send the hash, get PersistedQueryNotFound, send
the full document once — is an ecosystem convention popularised by Apollo, not a
requirement of either specification. It works, it is widely deployed, and it is not
something to cite a spec section for.
The transport spec also draws a hard line that will sound familiar from episode 16:
“GET requests MUST NOT be used for executing mutation operations… the server MUST
respond with an appropriate 4xx status code and halt execution; status code 405
(Method Not Allowed) is RECOMMENDED.” The reason it gives is the point: “This
restriction is necessary to conform with the long-established semantics of safe
methods within HTTP.” A newer protocol layered on HTTP still has to honour the rule
that keeps crawlers from changing your data.
Where the errors went, and the status code the video wouldn’t name
In GraphQL, an error is a member of the response body. The specification, §7.1.2:
“The errors entry in the response is a non-empty list of errors, where each error
is a map. If no errors were raised during the request, the errors entry should not
be present in the result.” And, crucially, data and errors can travel together: “If
the data entry in the response is present (including if it is the value null), the
errors entry in the response may contain any field errors that were raised during
execution.”
That is a real design decision, not an accident. A query asking for twelve fields where one resolver failed has produced eleven useful fields, and there is no single HTTP status code that means “eleven of twelve.”
The video stopped there, on purpose, because how this travels over HTTP is a different document — and the responsible thing is to say what that document actually recommends rather than repeat the folklore that “GraphQL always returns 200.”
It doesn’t, and the rules are specific. From the GraphQL-over-HTTP specification’s Status Codes section:
- If the result has a
dataentry that is not null, “the server MUST reply with a2xxstatus code.” - If it has
dataand noerrors, “the server SHOULD reply with a200status code.” - If it has both
dataanderrors, “the server SHOULD reply with a294status code.”
294 is not a typo, and it is not registered with IANA. The spec invented it,
and explains why at length: “There’s currently not an approved official HTTP status
code to use for a ‘partial success’. Contenders include 203 Non-Authoritative information (which indicates the response has been transformed), 206 Partial Content (which requires the Range header), and WebDAV’s status code 207 Multi-Status (which ‘provides status for multiple independent operations’). None
of those quite fit GraphQL’s needs, so we recommend using the custom code 294 Partial Success.”
And here is where episode 17’s rule pays off. A custom status code is only usable
because RFC 9110 §15 says an unrecognised code is treated as the x00 of its class
— which the spec leans on explicitly: HTTP clients that don’t recognise 294 “must
treat it as equivalent to 200 OK (notwithstanding that 294 does not appear in
the list of status codes that are heuristically cacheable).” The first digit really
is the whole contract, and a new code in the 2xx class is safe precisely because of
it. The parenthetical is the sharp end: 294 is not heuristically cacheable,
while 200 is, so a partial success will not be stored by default. That is almost
certainly what you want, and it is a consequence nobody designed on purpose.
Two caveats before anyone ships this. First, the spec is candid about its own
status: Stage 2: Draft — “this spec is not yet official, but is now a fully
formed solution… it is unwise to rely on a draft in a production GraphQL Service.”
Second, its own warning about infrastructure: “because 294 is unlikely to appear
in such lists by default, 294 might be handled differently from 200.” Your API
gateway, your WAF and your metrics pipeline all have opinions about status codes,
and a code none of them has heard of is a thing to test rather than assume.
The N+1 problem, moved into resolvers
A GraphQL server resolves a query field by field. Ask for fifty products and each one’s seller, and the naive implementation runs one query for the products and then one per product for the seller. That is exactly the N+1 problem from episode 12, relocated: same shape, same cause, new place to not notice it.
What makes it sharper here is that the client chose the query, so the client chose the cost. In a REST endpoint, the person who wrote the handler can see every query the handler runs. In GraphQL, a field that is cheap in one query is expensive inside a list in another, and nobody reviewed the combination.
The standard fix is batching: collect the ids requested during one tick of the event
loop, issue a single WHERE seller_id IN (…), and hand each resolver its slice.
DataLoader is the well-known implementation of that pattern and every server library
has an equivalent. It is not in either specification — it is what implementations
do, and it is close to mandatory in practice.
Depth limits, and paying for the client’s choices
The same “the client chose” property has a blunter version. Nesting is the client’s call, and a schema where a product has a seller who has products makes a query that recurses as deep as the attacker has patience for.
Servers respond with limits: a maximum query depth, and query cost analysis, where each field carries a weight, the server prices the document before executing it, and rejects anything over a budget. Neither is in the October 2021 specification. They are what servers add, and a public GraphQL endpoint without at least one of them has handed strangers a lever on your database.
Notice that REST never needed this. The server enumerated the shapes in advance, so there was no arbitrary query to price. The cost analysis is not overhead bolted onto GraphQL — it is the bill for the flexibility, arriving.
When REST is simply right
A cacheable public read API is the case where the server deciding the shape is the entire feature, not a limitation. If a million people want the same product page, one representation at one address that any CDN can store is not a compromise — it is the best available answer, and GraphQL’s flexibility buys you nothing you can use while costing you the thing you needed most.
The inverse is just as clear. A mobile client on a bad connection, assembling one screen from six resources, with a release cycle measured in app-store reviews, is the case field-level specification was invented for. Shipping a new REST endpoint per screen is the alternative, and it is worse.
REST’s author priced his own style honestly, in the chapter that defines it: “The REST interface is designed to be efficient for large-grain hypermedia data transfer, optimizing for the common case of the Web, but resulting in an interface that is not optimal for other forms of architectural interaction.”
That is not a concession extracted from him. It is the correct description of a trade, made by the person best placed to describe it — and it is the same sentence whichever of the two you end up choosing.
Test Your Understanding
Quick architectural scenario challenge based on this breakdown.
A 3rd-party payment gateway starts timing out with 504 errors during peak traffic.
Q1.How should a downstream dependency failure be handled to protect upstream service availability?
Comments –
Sign in to share your thoughts
Nothing yet. Be the first.