REST vs GraphQL

Two ways to expose data over HTTP, with genuinely different strengths. REST gives you many resource endpoints and the whole HTTP toolchain; GraphQL gives you one endpoint, a typed schema and client-specified response shapes. This guide compares them on the things that actually bite — caching, errors, N+1 queries, query cost and observability — without declaring a winner. You can send both kinds of request from the API tester while you read.

The core architectural difference

A REST API exposes many URLs, each identifying a resource, and uses the HTTP method as the verb: GET /v1/users/42, POST /v1/orders, DELETE /v1/orders/42. The server decides what a response contains. If you want more or less than it returns, you either add a query parameter the server explicitly supports or you make another request. The background is covered in what is a REST API.

A GraphQL API exposes one endpoint — conventionally POST /graphql — and a schema written in the Schema Definition Language. The schema is a type system: it declares every object, field and relationship the API can express, and it is introspectable, so tooling can discover the whole API at runtime. The client sends a query naming exactly the fields it wants, and the server returns a JSON document whose shape mirrors the query under a top-level data key.

That single difference — who decides the response shape — is where almost every other trade-off on this page comes from. When the server decides, everything downstream of the server can reason about the response: caches can key on the URL, gateways can enforce policy per route, and the cost of a request is bounded by code you wrote. When the client decides, the client gets exactly what it needs in one round trip, and all of that downstream reasoning has to be rebuilt inside the GraphQL layer.

Over-fetching and under-fetching

Take a concrete screen: a mobile account page showing a user's display name and the totals of their last three orders. Nothing else.

The REST version

With REST you typically send GET /v1/users/42 and get back the full user representation — name, email, address, preferences, timestamps, avatar URLs — of which you use one field. That is over-fetching. You then need the orders, which the user resource did not include, so you send GET /v1/users/42/orders?limit=3 as a second round trip, and each order arrives with its line items and shipping details when you only wanted total. That second trip is under-fetching: the first response did not contain enough to render the screen. On a mobile network with 200ms of latency, two sequential dependent requests cost close to half a second before the first pixel.

The GraphQL version

GraphQL expresses the same requirement in one operation:

query AccountScreen($id: ID!) {
  user(id: $id) {
    displayName
    orders(last: 3) {
      id
      total
    }
  }
}

and the response mirrors it exactly:

{
  "data": {
    "user": {
      "displayName": "Ada Lovelace",
      "orders": [
        { "id": "118", "total": 4998 },
        { "id": "104", "total": 1250 },
        { "id": "091", "total": 32000 }
      ]
    }
  }
}

One round trip, no wasted bytes, and the client team changed the payload without asking the API team for a new endpoint. That is GraphQL's headline benefit and it is a real one. The honest counterweight: REST can close much of the gap with sparse fieldsets (GET /v1/users/42?fields=display_name), embedded expansions (?expand=orders) or a purpose-built endpoint for that screen. Those work well, they just have to be designed, built and documented per case — which is exactly the coordination cost GraphQL is trying to remove.

Side-by-side comparison

DimensionRESTGraphQL
Endpoint modelMany URLs, one per resourceUsually one URL, POST /graphql
Who shapes the dataServer, fixed per endpointClient, per query
Type systemOptional, via OpenAPI or JSON SchemaMandatory, in the SDL schema
VersioningExplicit versions: /v1, /v2, media typesContinuous evolution; fields marked @deprecated
HTTP cachingNative — URL as key, ETag, Cache-Control, CDNNot native over POST; needs client cache or persisted queries
Discovery and toolingOpenAPI docs, curl, browser address barIntrospection, schema-aware editors and autocompletion
File uploadsNative multipart/form-dataNot in the spec; community multipart spec or a separate REST route
Error signallingStatus code carries the outcomeConventionally 200 with an errors array
Partial successNot really expressibleNormal — data with nulls plus errors
Learning curveLow; it is just HTTPHigher; new language, schema design, resolver model
Server complexityLower; each handler is boundedHigher; resolvers, batching, cost limits, per-field auth
Rate limitingRequests per windowBetter done by query complexity or points
ObservabilityMetrics per route and methodEvery request is one route; needs operation names

Caching: the biggest practical divergence

HTTP was designed with caching as a first-class concern, and REST inherits all of it for free. A GET request has a URL, the URL is the cache key, and the response can declare its own freshness with Cache-Control: public, max-age=300. Add an ETag and a client can revalidate with If-None-Match, receiving 304 Not Modified with no body when nothing changed. Because the layered-system property means intermediaries are invisible, a CDN can serve that response from an edge node near the user without your origin being touched at all. The HTTP headers guide covers the directives.

A GraphQL query sent as POST /graphql defeats every part of that. All requests share one URL, so the URL is useless as a key; the distinguishing information is in the request body, which HTTP caches do not inspect; and POST responses are only cacheable with explicit freshness information, which in practice essentially no shared cache implements. Caching therefore moves elsewhere, and there are three real answers:

  • Normalised client caches. Apollo Client and Relay parse responses into a flat store keyed by object type and id, so User:42 fetched by one query is reused by another query that needs the same object. This is powerful — it gives you cross-query consistency that HTTP caching cannot — but it lives in the client, so it does nothing for a cold start, a second device, or server load.
  • Persisted queries. The client registers its query text with the server ahead of time and afterwards sends only a hash. Automatic persisted queries (APQ) do the registration on first use. Since the request is now short, it can be sent as GET /graphql?id=abc123&variables=…, which restores a distinct URL, and with it ETag, Cache-Control and CDN caching. This is the route production GraphQL deployments take when public read traffic matters.
  • Caching below the resolvers. Memoising data-source calls and putting Redis in front of expensive lookups. Effective, but it is application-level work you write and maintain yourself.

Worth stating plainly: GraphQL can be served over GET for queries, and the GraphQL-over-HTTP specification work encourages supporting it. The default in most client libraries and server frameworks is POST — partly because query documents can exceed practical URL length limits — but it is a default, not a constraint. Mutations should stay on POST, for the same reasons state changes do not belong on GET in REST; see the HTTP methods reference.

Error handling: where expectations break

In REST, the status code is the outcome. 400 means malformed, 401 means unauthenticated, 404 means no such resource, 500 means the server broke. Generic middleware you did not write — retry policies, circuit breakers, alerting rules, CDN error pages — all work off that number without knowing anything about your domain.

GraphQL conventionally returns 200 OK even when resolvers fail, with the failures listed in a top-level errors array:

{
  "data": { "user": { "displayName": "Ada Lovelace", "orders": null } },
  "errors": [
    {
      "message": "Order service unavailable",
      "path": ["user", "orders"],
      "extensions": { "code": "DOWNSTREAM_UNAVAILABLE" }
    }
  ]
}

The reasoning is sound: a single query can touch a dozen resolvers, and if eleven succeed and one fails there is no single status code that honestly describes the result. Partial success is normal in GraphQL, and it is genuinely useful — the account screen above can render the user's name even though the order service is down. Note also how a null propagates: a non-null field that errors nulls its nearest nullable ancestor, so schema nullability decisions directly determine how much of a response survives a partial failure.

The cost is that everything keyed on status codes goes blind. A retry layer that retries on 5xx will not retry a GraphQL error. An uptime monitor hitting POST /graphql sees a wall of 200s while every query fails. Dashboards show a healthy error rate during an incident. Teams adopting GraphQL almost always have to write GraphQL-aware middleware to inspect errors and re-derive severity from extensions.code.

This is evolving rather than settled. The GraphQL-over-HTTP specification work defines the media type application/graphql-response+json and, under it, allows non-200 statuses for request errors — a malformed document, a validation failure, an unparseable body — while keeping 200 for field errors that occurred during execution. That is a sensible split, but support across servers, gateways and client libraries is uneven, so do not assume it. Check what your stack actually returns; the status code reference covers what each code should mean when it does appear.

The N+1 problem

A GraphQL server resolves a query field by field. A resolver for a list field runs once per parent object, and if that resolver hits the database, you get one query per parent. Ask for 100 orders and each order's customer, and the server runs one query for the orders plus 100 for customers — the N+1 problem. It is easy to introduce accidentally because the resolver in isolation looks perfectly reasonable: it fetches one customer by one id. Nothing in the resolver knows it is about to be called ninety-nine more times.

The standard mitigation is a DataLoader: a per-request object that collects the keys requested during a single tick of the event loop, issues one batched query (WHERE id IN (…)), and hands each resolver its result. It also memoises within the request, so asking for the same customer from twenty orders hits the database once. The two properties matter equally — batching kills the N+1, per-request caching kills the duplicates — and the per-request scoping is what stops one user's authorised data leaking into another user's query.

REST is not innocent here. It has the same problem one layer up, at the client: fetch a list of orders, then loop over it issuing GET /v1/customers/{id} for each. That is N+1 over the network, which is far more expensive per iteration than N+1 against a local database. REST's mitigations — an ?expand=customer parameter, a batch endpoint, or simply denormalising the customer name into the order representation — work, but each is a design decision someone has to make in advance. GraphQL moves this problem from the client to the server, where it is cheaper to solve but easier to hide.

Other real trade-offs

Query cost and denial of service

Because the client composes the query, a GraphQL endpoint accepts requests whose cost the server did not choose. A schema with a bidirectional relationship — orders have a customer, a customer has orders — lets a caller nest arbitrarily deep and multiply the work at every level, from a request that is a few hundred bytes long. There is no direct REST equivalent: a REST handler returns a shape you wrote, so its worst case is bounded by your code and by your pagination limits.

The countermeasures are well established, and you need them before going public: cap query depth, cap the number of aliases and nodes, assign a cost per field and reject queries over a budget, require pagination arguments on list fields, set an execution timeout, and consider allowing only persisted queries so that arbitrary documents are never executed at all. Disabling introspection in production is common too, though treat it as friction rather than security.

Rate limiting

Counting requests works poorly when one request can be a thousand times more expensive than another. Mature GraphQL APIs — GitHub's is the well-known example — rate limit by computed query cost instead, giving each caller a points budget per window and charging each query according to how many nodes it could return. The client is told its remaining points in the response. REST's request-count limiting is coarser but far simpler to implement, explain and enforce at a gateway. Either way, answer excess traffic with 429 and a Retry-After; see the API rate limiting guide.

Authorisation granularity

REST lets you attach authorisation to a route and a method: this role may GET /v1/orders, that role may also DELETE them. Coarse, but easy to audit — you can read the route table and see the policy. In GraphQL a single query can reach any part of the graph, so authorisation must be enforced per field, close to the resolver, with the viewer's context threaded through. That is more flexible and more places to get it wrong: a sensitive field exposed on a type that is reachable through an unexpected path is the classic GraphQL vulnerability. Whichever model you use, the credential layer is the same — bearer tokens and the authentication options apply identically to both.

Observability

REST gives you observability for free: metrics per route and method, so a latency spike on GET /v1/orders is immediately visible in any HTTP dashboard. With GraphQL, every request is POST /graphql, and URL-based metrics tell you nothing except that traffic exists. You need instrumentation that records the operation name — which is why naming every operation (query AccountScreen, not an anonymous query) should be a lint rule, not a preference — plus per-resolver timing to find which field in a slow query is actually slow. Good APM integrations exist, but this is setup work REST does not require.

Choosing between them

There is no general answer, only a fit between the shape of your clients and the shape of your data. This table is a starting point, not a verdict.

ScenarioLeansBecause
Public API for unknown third-party consumersRESTLower learning curve, works with curl and any HTTP client, cost is bounded by your code
Many client platforms with divergent needsGraphQLEach client takes the fields it needs without a backend release
Simple CRUD over a handful of entitiesRESTThe schema, resolver and batching machinery would not earn its keep
File-heavy workloadsRESTMultipart uploads, ranged downloads and streaming are native to HTTP
Public content served through a CDNRESTURL-keyed edge caching works without persisted-query infrastructure
Aggregating many microservices for one UIGraphQLOne schema stitches sources; the client stops orchestrating calls
Deeply relational data explored in varied waysGraphQLTraversal is what the query language is for
Small team, tight deadline, no GraphQL experienceRESTOperational burden and the failure modes above are real ongoing costs
Rapidly iterating product with a dedicated frontend teamGraphQLRemoves the round of backend work per UI change
Strict per-endpoint audit and compliance requirementsRESTRoute-level policy is far easier to enumerate and prove

Notice how many of these turn on organisational facts rather than technical ones. GraphQL's clearest wins are where the bottleneck is coordination between backend and frontend teams; REST's clearest wins are where the bottleneck is operational simplicity, edge caching or an audience you will never meet.

They coexist more often than they compete

The framing of a contest is mostly a blogging artefact. In real architectures the two sit in layers. A common pattern is the backend-for-frontend: a GraphQL server owned by the client team, which holds no data of its own and resolves fields by calling internal REST or gRPC services. The client gets one tailored round trip; the services stay simple, independently deployable and cacheable behind the gateway.

Federated GraphQL extends this — several teams own subgraphs, and a gateway composes them into one schema — and it is common to see a REST API and a GraphQL API over the same domain, with REST as the stable public contract and GraphQL for first-party applications. GitHub is the standard example: a mature REST API and a GraphQL API, maintained in parallel, with each documented for different jobs. Adopting one does not require discarding the other, and an incremental GraphQL layer over existing REST endpoints is a low-risk way to find out whether the benefits show up for your workload.

Try both

The differences make far more sense when you watch the traffic. Open the API tester and send GET https://api.github.com/repos/torvalds/linux: note the full representation you did not ask for, the ETag, and the Cache-Control header — all of REST's caching story in one response. Replay it with If-None-Match and watch it come back as 304 with no body.

Then open the GraphQL tester and send a query against a public endpoint such as https://countries.trevorblades.com/. Ask for { country(code: "GB") { name currency } }, then add and remove fields and watch the response shape follow exactly. Ask for a field that does not exist and look at what comes back — a 200, or something else, depending on the server — which is the error-handling section made concrete. For side-by-side request building against conventional endpoints, the REST API tester has the method, header and auth panes, and the REST API testing guide covers turning experiments into a suite.

Frequently asked questions

Is GraphQL a replacement for REST?

No — different tools, different strengths. GraphQL removes over-fetching and round trips for clients with varied needs; REST keeps HTTP caching, status-code semantics and operational simplicity. Many organisations run both, often with GraphQL as a gateway in front of REST services.

Why does GraphQL return HTTP 200 on errors?

Because a response can be partially successful — some resolvers succeed, others fail — so one status code cannot describe it. The convention is 200 with an errors array. Newer GraphQL-over-HTTP work adds application/graphql-response+json and allows non-200 for request-level errors, but adoption is uneven.

Can GraphQL responses be cached by a CDN?

Not by default, because queries go out as POST with the query in the body and URL-based caches have nothing to key on. Persisted queries sent as GET restore URL caching and CDN support; otherwise clients cache locally in a normalised store keyed by object id.

What is the N+1 problem in GraphQL?

A nested-field resolver runs once per parent, so 100 orders each asking for a customer produce 101 queries. DataLoader-style per-request batching plus caching collapses them into one batched lookup.

How does versioning differ between REST and GraphQL?

REST publishes discrete versions such as /v1 and /v2. GraphQL evolves one schema continuously, adding fields and marking old ones @deprecated — and because clients request fields by name, the server can measure who still uses a field before removing it.

Is GraphQL harder to secure than REST?

There is more to reason about. Authorisation is per field rather than per route, and an unbounded query language is a denial-of-service vector with no REST equivalent. Depth limits, query cost analysis, persisted queries and disabling introspection in production are the usual controls.