GraphQL or REST: Choosing per Use Case, Not per Project

GraphQL and REST are usually treated as an either/or decision made once, at the start of a project. Comparing them on payload shaping, reference resolution, caching, tooling and error handling shows that the honest answer differs per request, and that mixing both in one application is a reasonable design rather than a failure of discipline.


Most headless content systems, Squidex included, expose the same content through more than one API. The stored documents are identical, the permission model is identical, the query capabilities overlap heavily. What differs is the shape of the contract between your application and the content.

Because the decision usually gets made once, in an architecture meeting, it tends to be framed as a project-level choice: "we're a GraphQL shop" or "we use REST everywhere". That framing hides the fact that the two APIs are good at different things, and that a single frontend often has both kinds of request in it.

Payload shaping

This is the difference everyone knows. With REST you get the representation the server decided on. With GraphQL you get the fields you asked for.

For a landing page that pulls a headline, a teaser and a hero image out of documents that also contain a full body, an SEO block and a dozen editorial fields, the difference is real. Over a mobile connection, shipping 40 KB of JSON to render 400 bytes of text is a measurable cost.

Many REST APIs offer a field projection parameter, which closes part of the gap:

GET /content/articles?fields=title,teaser,heroImage&limit=10

That covers flat selection. What it does not cover well is different selection at different depths — the full body for the first item, only titles for the rest. GraphQL handles that without inventing a query-parameter dialect:

{
  featured: articles(top: 1) {
    title
    body
    author { name avatar { url } }
  }
  recent: articles(top: 10, skip: 1) {
    title
    slug
  }
}

If you never need that, projection parameters on REST are enough and considerably less machinery.

Reference resolution

This is the difference that actually decides most cases, and it gets less attention than payload size.

Content models are graphs. An article references an author, the author references an organisation, the article references a category and three related articles, each of which has a cover image asset. Rendering one page means walking that graph.

Over REST, you walk it with requests. You fetch the article, read the reference IDs, fetch the referenced items, then fetch what those reference. Three levels deep on a page with a dozen components is a waterfall, and each round trip pays the full latency between your renderer and the API. Some REST APIs let you resolve a level of references inline, which flattens the first hop but rarely the whole tree.

Over GraphQL, the traversal is expressed in the query and resolved server-side in one round trip. For page composition — one request that assembles everything a route needs — this is the strongest argument in GraphQL's favour, stronger than payload size.

The inverse also holds. A search-as-you-type endpoint returning ten titles has no graph in it. Resolving references is not a problem you have, so the tool that solves it buys you nothing.

Caching

Here REST wins on infrastructure you already own.

A GET request with a stable URL is cacheable by every layer between the client and the origin: the browser cache, the service worker, a CDN, a reverse proxy. ETag and If-None-Match work. Cache-Control works. Invalidating a path prefix at the CDN works.

GraphQL is conventionally a POST to a single endpoint, which means the URL carries no information and none of that machinery applies. You can work around it — persisted queries turn a query into an ID that can travel in a GET, and client-side normalised caches like Apollo or urql cache at the entity level rather than the response level — but these are additional systems to run and reason about, not properties you get for free.

For content that is read far more often than it is written, and served to anonymous users, the boring cacheability of REST is worth a lot. For an authenticated dashboard where every response is user-specific and uncacheable anyway, you give up nothing by using GraphQL.

Tooling and typing

GraphQL ships a machine-readable schema as part of the protocol. That gives you introspection, editor completion, and generated TypeScript types for queries and responses without maintaining a separate contract file. When a field is removed from a content type, code generation fails at build time instead of at 2 a.m.

REST can reach a similar place through OpenAPI, but the schema is a separate artefact that has to be kept accurate. Whether it is accurate depends on how it is produced.

Against that, REST needs no client library. curl, fetch, a shell script, a webhook consumer in a language with no GraphQL tooling — all of them can talk to it today. For server-to-server integrations, build scripts and one-off data pulls, that matters more than generated types.

Error handling

REST puts the outcome in the status line. 404 means missing, 403 means forbidden, 429 means slow down. Every HTTP client, proxy, retry policy and monitoring dashboard already understands this.

GraphQL usually answers 200 OK and puts failures in an errors array alongside whatever data it managed to resolve. Partial success is expressible, which is genuinely useful when one component of a page fails and the rest should still render. It also means your retry logic and your alerting cannot rely on status codes; something in your stack has to inspect the body. If your observability is built on HTTP status metrics, GraphQL requests will look uniformly healthy while failing.

Mixing them on purpose

Once the comparison is laid out per dimension, the per-project framing stops making sense. In one application:

  • The route-level page composition query — deep, sparse, latency-sensitive, one request per render — is a good fit for GraphQL.
  • The paginated article list behind a CDN, the sitemap generator, the incremental build script that pulls changed items — flat, cacheable, no graph traversal — are a good fit for REST.
  • The webhook receiver that fetches one item by ID after a change notification does not need a query language at all.

The cost of mixing is real but bounded: two auth code paths if the APIs authenticate differently, two error-handling shapes, and a slightly larger surface for new team members to learn. Against that, you avoid pushing every request through the model that suits half of them.

A rough decision rule

Ask three questions about the specific request, not about the project:

  1. Does it traverse references more than one level deep? If yes, lean GraphQL.
  2. Is the response identical for many users and read far more than written? If yes, lean REST and let a CDN serve it.
  3. Is the consumer a browser with a typed client, or a script, a build step, another service? Typed clients benefit from the schema; scripts benefit from a URL.

When the answers conflict, latency and caching usually dominate over developer convenience, because they show up in production and convenience shows up in review.

If you want to test this on your own model, take the heaviest route in your application, count the round trips it currently makes and the bytes it discards, then do the same for your simplest list endpoint. The two numbers will usually point in different directions, and that is the point.