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
| Dimension | REST | GraphQL |
|---|---|---|
| Endpoint model | Many URLs, one per resource | Usually one URL, POST /graphql |
| Who shapes the data | Server, fixed per endpoint | Client, per query |
| Type system | Optional, via OpenAPI or JSON Schema | Mandatory, in the SDL schema |
| Versioning | Explicit versions: /v1, /v2, media types | Continuous evolution; fields marked @deprecated |
| HTTP caching | Native — URL as key, ETag, Cache-Control, CDN | Not native over POST; needs client cache or persisted queries |
| Discovery and tooling | OpenAPI docs, curl, browser address bar | Introspection, schema-aware editors and autocompletion |
| File uploads | Native multipart/form-data | Not in the spec; community multipart spec or a separate REST route |
| Error signalling | Status code carries the outcome | Conventionally 200 with an errors array |
| Partial success | Not really expressible | Normal — data with nulls plus errors |
| Learning curve | Low; it is just HTTP | Higher; new language, schema design, resolver model |
| Server complexity | Lower; each handler is bounded | Higher; resolvers, batching, cost limits, per-field auth |
| Rate limiting | Requests per window | Better done by query complexity or points |
| Observability | Metrics per route and method | Every 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:42fetched 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 itETag,Cache-Controland 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.
| Scenario | Leans | Because |
|---|---|---|
| Public API for unknown third-party consumers | REST | Lower learning curve, works with curl and any HTTP client, cost is bounded by your code |
| Many client platforms with divergent needs | GraphQL | Each client takes the fields it needs without a backend release |
| Simple CRUD over a handful of entities | REST | The schema, resolver and batching machinery would not earn its keep |
| File-heavy workloads | REST | Multipart uploads, ranged downloads and streaming are native to HTTP |
| Public content served through a CDN | REST | URL-keyed edge caching works without persisted-query infrastructure |
| Aggregating many microservices for one UI | GraphQL | One schema stitches sources; the client stops orchestrating calls |
| Deeply relational data explored in varied ways | GraphQL | Traversal is what the query language is for |
| Small team, tight deadline, no GraphQL experience | REST | Operational burden and the failure modes above are real ongoing costs |
| Rapidly iterating product with a dedicated frontend team | GraphQL | Removes the round of backend work per UI change |
| Strict per-endpoint audit and compliance requirements | REST | Route-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.