Home/ Blog/ Article

REST or GraphQL: choosing by constraint, not by fashion

·

The REST versus GraphQL argument is usually conducted as a question of taste, which is why it never resolves. It is a question about constraints: how many clients you serve, who controls them, how much you depend on caching, and what operational budget you have for query cost control. Answer those honestly and the choice makes itself — frequently in favour of REST, which is not what the discourse expects.

Start with who consumes the API

GraphQL was built for a specific problem: many heterogeneous clients evolving at different speeds, each wanting a different shape of the same data, none of which the API team can coordinate releases with. A mobile app on a slow rollout, a web app shipping daily, a partner integration. Under those conditions, letting the client specify what it needs is cheaper than maintaining a growing set of bespoke endpoints.

Now count your actual clients. If the answer is “one web front end, which we also build”, the problem GraphQL solves does not exist in your system. You can add a field and deploy both sides the same afternoon. The flexibility you would be buying is flexibility you never spend, and you pay for it in schema maintenance, resolver plumbing, caching complexity and harder performance reasoning.

The distinction that matters is not client count but coupling. Two clients on the same release train behave like one. Two you cannot deploy — an installed mobile app with users on old versions, or third parties building against your API — behave like many.

Over-fetching and under-fetching, measured rather than assumed

The standard case for GraphQL is that REST makes you download fields you do not need and issue several round trips to assemble one screen. Both are real, and both are frequently overstated.

Over-fetching costs bandwidth and serialisation. On a fast connection with gzip or brotli, sending twenty fields where the client uses six is usually noise. On a poor mobile connection with a large payload, it is not. Measure your payloads on your worst-case network rather than arguing from principle.

Under-fetching — the waterfall of requests to build one view — is the more serious complaint, because latency compounds. But REST has answers that need no new paradigm. An endpoint that returns exactly what a screen needs is a respectable design; the objection is aesthetic (“that’s not RESTful”) rather than practical. Sparse fieldsets and inclusion parameters cover much of the rest.

GET /projects/42?fields=name,status&include=owner,latest_deployment

# one round trip, only the fields asked for, still cacheable by URL

If you find yourself building a large ad hoc dialect of that — nested includes, filters on included resources, per-field selection several levels down — you are reimplementing GraphQL badly and should adopt it properly. That threshold is a genuine signal. A handful of include parameters is not.

Caching, where REST wins and why

This is the most consequential difference and the least discussed. REST resources are identified by URL and fetched with GET, so the entire HTTP caching ecosystem works on them for free: browser cache, CDN edge, reverse proxy, conditional requests with ETags, stale-while-revalidate. A well-designed read-heavy REST API serves much of its traffic without the origin being touched.

GraphQL discards most of that by default. Queries are POSTed to a single endpoint, so there is no cache key an intermediary understands, and no two clients ask precisely the same question anyway. You get it back only by working for it: persisted queries mapped to identifiers so they can be sent as cacheable GETs, normalised client-side caching, and response caching keyed on resolved entities. All known techniques, all more moving parts than a Cache-Control header.

So: if your workload is read-heavy, public or semi-public and cacheable at the edge, REST starts with a structural advantage that is hard to give back. If it is authenticated, highly personalised and barely cacheable anyway — an internal dashboard, a logged-in application shell — you give up much less, and the comparison shifts.

The cost of nested queries and depth attacks

A GraphQL schema invites the client to compose queries the server author never wrote. That is the feature and the liability. Two problems arrive together.

The first is the N+1 pattern. A query for fifty projects, each with its owner, calls the owner resolver fifty times. Batching with a dataloader-style layer fixes it, but that is something you must actively build and keep working as the schema grows; its absence shows up as a database falling over under a query that looked innocuous.

The second is deliberate abuse. If your schema has any cycle — a user has projects, a project has members, a member is a user — a caller can nest it to arbitrary depth and buy exponential server work with a few hundred bytes. On a public API that is a denial-of-service vector requiring no cleverness to find.

query {
  user(id: "1") {
    projects { members { projects { members { projects { name } } } } }
  }
}

The mitigations are depth limiting, static complexity scoring against a per-caller budget, timeouts, mandatory pagination on list fields, and — for anything public — an allow-list of persisted queries, so arbitrary documents are rejected. Note what that last one does: it converts your GraphQL API back into a fixed set of operations, which is to say, into endpoints. Worth asking early whether you needed the general case at all.

Versioning

GraphQL’s pitch is that you never version: add fields freely, deprecate old ones, remove them once usage drops to zero. Because the server sees which fields each query requests, you can measure that usage precisely — a real advantage, and one REST rarely matches without extra instrumentation.

The catch is that “never version” quietly means “never remove”. Deprecated fields accumulate, resolvers stay alive for one partner who never migrated, and the schema becomes a museum of past product decisions. Avoiding versions converts an explicit, scheduled migration into a permanent background obligation. Some teams prefer that. It is a trade, not a free win.

REST versioning is blunter and more visible: a version in the path or media type, two implementations alive during transition, a sunset date communicated to consumers. Blunt is not bad. When you need to change a resource’s semantics rather than add to it, a version boundary is clearer than a field named statusV2.

The cost of running both

A common compromise is to run a GraphQL layer over existing REST services, or expose both to different audiences. It can be right, particularly when a gateway aggregates several backends for one demanding front end. But price it properly, because the cost is not the gateway code.

  • Two sets of authorisation rules that must agree. Field-level permissions in a schema and route-level permissions in REST diverge, and that is where security holes appear.
  • Two error models, two pagination conventions, two sets of client libraries and two bodies of documentation to keep in step.
  • Harder debugging, because a slow query now crosses a gateway and several services, and the trace has to survive that.
  • A team that must be competent in both, indefinitely.

If the second API serves a real, identified consumer the first cannot, that expense is justified. If it exists because the team wanted to try GraphQL, it will be paid every sprint for years.

When REST is still the right answer

Choose REST, without apology, when most of these hold: you have one or two clients you deploy yourself; your traffic is read-heavy and benefits from CDN or proxy caching; your consumers are third parties who want something simple, curl-able and boring; your data model is not deeply graph-shaped; you expose uploads, downloads or streaming, where HTTP semantics do the work; or your team is small enough that query cost control, schema governance and persisted queries would come out of feature time.

Choose GraphQL when you have several independently-deployed clients you cannot coordinate, when a screen genuinely assembles data from many sources, when your domain really is a graph traversed in unpredictable directions, and when you have the appetite to run the machinery that keeps it safe and fast.

The most useful question before deciding: what breaks if a client asks for a shape we did not anticipate? If the answer is “nothing, we’d add an endpoint next week”, REST is fine. If it is “we’d block a mobile release for six weeks”, you have found your constraint.

Filed under: