OwlTheEngineer
How It Actually WorksEpisode 18 · Backend

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.

Owl · 8 min read · 0 views
– comments
Ep 18 · Backend
60s VISUAL BREAKDOWN
Episode 18
Watch the fast visual breakdown or discuss on social:

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 data entry that is not null, “the server MUST reply with a 2xx status code.”
  • If it has data and no errors, “the server SHOULD reply with a 200 status code.”
  • If it has both data and errors, “the server SHOULD reply with a 294 status 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.

SYSTEM DESIGN CHECK

Test Your Understanding

Quick architectural scenario challenge based on this breakdown.

SCENARIO

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 –