When to Use GraphQL Over REST API - and When REST Is Still the Right Call
Deciding when to use GraphQL over REST API comes down to one question: who decides the shape of the response, the server or the client? In REST, the server owns it — each endpoint returns a fixed document, and the client takes what it gets. GraphQL inverts that: the client sends a query naming the exact fields it wants, and the server assembles a response with that shape and nothing else.
The problem this exists to solve is client/server response mismatch at scale. When you have one backend and one web client, the server can just return the right fields. When you have a web app, an iOS app, an Android app, and a partner integration all reading the same data with different needs, you either ship a fixed endpoint that overserves everyone, or you accumulate /users/:id/mobile-summary-style variants that nobody dares delete.
GraphQL trades a fixed contract for a flexible one. That flexibility is real, and so is the operational bill it comes with: your performance and security story moves from the URL layer, where every cache and proxy already understands it, into your own resolver code.
| Concept | GraphQL (vs REST) |
|---|---|
| Where it sits | API layer — sits between your clients and your services, replacing or fronting HTTP endpoints |
| In one sentence | GraphQL is a query language for APIs that lets the client specify exactly which fields it needs in a single request, instead of accepting whatever a fixed REST endpoint returns. |
The 60-Second Answer
What it does: clients send a query naming the fields they want; the server returns exactly that shape, resolving each field through server-side functions.
The one problem it solves: many different clients needing different slices of the same data, without the backend shipping a new endpoint (or a bloated one) for each.
The one cost it adds: you lose the free infrastructure that comes with URL-addressable endpoints. HTTP caching, per-route rate limits, per-route metrics, and predictable database load all become things you must rebuild yourself — usually as persisted queries, a dataloader batching layer, and query cost analysis.
Rule of thumb: if you have one client and a team that controls both ends, REST is very likely correct. GraphQL earns its keep when client count and screen variety grow faster than your API team can serve them.
How It Actually Works
The mechanism, step by step
- 1. The schema is the contract, and it is typed
- 2. The client sends a selection set; the server returns that shape
- 3. Every field is resolved by a function
- 4. One request replaces a waterfall of round trips
- 5. Overfetching and underfetching are both addressed, but only one is the real win
- 6. It usually runs over a single POST endpoint
- 7. Errors and status codes work differently
1. The schema is the contract, and it is typed
Types and fields, not URLs
A GraphQL API is defined by a schema: a set of types, their fields, and the field types. There is no route table. The client does not know /users/42 — it knows there is a user(id: ID!) field on Query that returns a User, and that User has name, email, and orders.
type User {
id: ID!
name: String!
orders(first: Int): [Order!]!
}
type Query {
user(id: ID!): User
}
The schema is machine-readable, which is where the tooling story comes from: editor autocomplete, generated client types, and build-time validation that a query is even legal. That is genuinely stronger than a hand-maintained OpenAPI file that drifts from the code.
⚠️ In production — The schema is a single shared surface, so it becomes a coordination bottleneck. Once several teams own types in one graph, every field addition is a design conversation, and every field removal is a hunt for who is still selecting it. Budget for schema governance early, not after the first outage caused by a 'nobody uses this' deletion.
2. The client sends a selection set; the server returns that shape
Client decides the response
A query is a nested selection set. The response JSON mirrors it exactly — same keys, same nesting. This is the mechanism behind the whole value proposition: a mobile list screen asks for two fields, and a desktop detail screen asks for twenty, against the same schema and the same server code.
query {
user(id: "42") { name orders(first: 3) { total } }
}
The server does not have a handler for that query. It has handlers for user, for orders, for total, and it composes them per request.
⚠️ In production — Because the response shape is client-controlled, you cannot reason about 'the payload' anymore — only about payloads. Log the operation name and the selection shape, or your latency percentiles will average a two-field query together with a deeply nested one and tell you nothing.
3. Every field is resolved by a function
Resolvers walk the query tree
The server executes a query by walking the selection set and calling a resolver for each field. A resolver receives the parent object and returns the field's value — from a database, another service, a cache, or just a property already in memory.
This is why GraphQL federates well across services: a field on User can be backed by your user database while a field on Order hits the orders service, and the client never sees the seam. It is also why performance is a per-field property rather than a per-endpoint one.
⚠️ In production — Resolvers are called per node, not per query. Fetch 50 users and select one field that hits the database, and you get 50 extra calls — the N+1 problem, arriving by default. Every serious GraphQL deployment ends up with a per-request batching-and-caching layer (the dataloader pattern) in front of its data sources. Treat that as part of the setup cost, not an optimisation for later.
4. One request replaces a waterfall of round trips
Nesting collapses client-side chaining
Under REST, a screen showing a user, their recent orders, and each order's product often means fetching the user, then the orders, then the products — each round trip blocked on the last. Over a high-latency mobile network, that chain, not the payload size, is usually what the user feels.
GraphQL collapses the chain into one round trip because the nesting is expressed in the query. The server still does the same underlying work, but it does it inside the datacentre where the hops are cheap.
⚠️ In production — The waterfall does not disappear; it moves to your resolvers, where it is now invisible to client-side network panels. A query that looks instant in a schema explorer can be doing sequential internal calls. Instrument resolver-level timing from day one, or you will be debugging a slow screen with no idea which field is responsible.
5. Overfetching and underfetching are both addressed, but only one is the real win
Bytes versus round trips
Overfetching is the endpoint returning fields you did not want. Underfetching is it not returning enough, so you make another call. GraphQL addresses both, and the marketing usually leads with overfetching — smaller payloads.
In practice the payload saving is often modest, since compression handles repetitive JSON well. The durable win is underfetching: removing round trips and, more importantly, removing the need to ship a backend change every time a screen's data needs shift.
⚠️ In production — If you are adopting GraphQL to shrink response bodies, measure first. Teams have completed a migration and found the byte savings were within noise, while the resolver complexity was not. Response size is a weak justification; client iteration speed is a strong one.
6. It usually runs over a single POST endpoint
Where HTTP caching goes to die
A conventional GraphQL deployment exposes one URL — commonly /graphql — and every operation is a POST to it. That single detail is the source of most operational surprises, because the entire HTTP ecosystem keys on method and URL.
A CDN cannot cache a POST body it does not understand. A WAF cannot distinguish 'read one product' from 'export the catalogue'. Per-route rate limits collapse into one bucket.
⚠️ In production — The standard mitigations are persisted queries (client sends a hash of a pre-registered query; the server rejects anything unregistered) and query cost analysis (assign a cost to each field, reject queries over a budget). Both work, both are extra infrastructure you own. Without them, a public GraphQL endpoint lets anyone hand-write a deeply nested query that is expensive for them to send and expensive for you to serve.
7. Errors and status codes work differently
Partial success is the normal case
A GraphQL response can be partially successful: data holds what resolved, errors holds what did not, and the transport-level status is typically 200 either way. This follows from per-field resolution — if 9 of 10 fields resolved, throwing away the 9 would be wasteful.
{ "data": { "user": { "name": "Ada", "orders": null } },
"errors": [{ "message": "orders unavailable", "path": ["user","orders"] }] }
⚠️ In production — Anything that keys on HTTP status will report your API as healthy while it is quietly failing — uptime checks, load balancer health logic, client retry wrappers, alerting. Point your monitoring at the errors array, and make sure client code checks it. This is the single most common gap found in a first production GraphQL rollout.
- A retailer runs a web storefront, iOS and Android apps, and an in-store kiosk against the same catalogue. Each surface needs a different slice of a product — the kiosk wants stock by location, the app wants a thumbnail and price, the web wants full descriptions and reviews. With REST that is either one fat product endpoint or four variants; with GraphQL it is one
Producttype and four different selection sets. - A backend-for-frontend team fronts a dozen microservices with a GraphQL layer. The graph is the composition point:
Order.customerresolves against the customer service,Order.shipmentagainst logistics. Mobile developers stop filing tickets asking for a field to be joined server-side, because the join is expressed in the query. - A dashboard product lets customers build their own views. The set of data combinations a user might request is open-ended, so no fixed set of endpoints covers it. A schema plus a query cost limit fits this shape well — the flexibility is the product feature, not just an implementation convenience.
- A company opens a partner API where integrators pull whatever fields their own systems need. The typed, introspectable schema does real work here: partners discover the surface without a support ticket, and breaking-change management becomes field deprecation rather than URL versioning.
When You Actually Need It
- You have three or more distinct clients reading the same domain data with genuinely different field needs, and your API team is a queue that frontend teams wait in.
- Your mobile clients are making chained requests to render a single screen, and you can see the round-trip latency in your own traces — not just suspect it.
- You already run a backend-for-frontend or aggregation layer, and it is turning into a pile of one-off composite endpoints that exist to serve exactly one screen.
- Your API surface is genuinely exploratory — a dashboard builder, a reporting tool, a partner integration — where you cannot enumerate the useful data combinations up front.
- You ship mobile clients that stay in the field on old versions, and rolling out new endpoints per release is painful. Adding a nullable field to a schema is cheaper than versioning a URL.
- You have the operational capacity to own batching, query cost limits, persisted queries, and field-level observability. If nobody has time for those, the earlier signals do not matter.
When You Don't
- One client and one backend team. Below roughly a single web app plus maybe one mobile app, the coordination problem GraphQL solves does not exist yet — you can just change the endpoint. Use REST with well-shaped responses, and revisit when a third client shows up.
- A read-heavy public API where CDN caching is the performance strategy. If your traffic is mostly anonymous reads of shared content, REST plus cache headers plus a CDN gives you a huge win for near-zero engineering effort. Moving to POST-per-query throws that away and asks you to rebuild it in application code.
- File upload and download. GraphQL has no native binary transport, and the workarounds are awkward. Keep uploads on plain HTTP endpoints or pre-signed object storage URLs — even if the rest of your API is a graph. This is normal, not a compromise.
- Simple CRUD over a handful of resources. If your API is a dozen endpoints that map cleanly to tables and the response shapes are stable, a schema, resolver layer, and dataloader cache are pure overhead. REST plus OpenAPI-generated clients gets you the typed-client benefit at a fraction of the cost.
- Machine-to-machine calls between your own services. Internal callers are known, their queries are fixed, and you control both sides — the client-flexibility argument evaporates. Use REST, gRPC, or direct messaging, where per-route timeouts and back-pressure are simpler to reason about.
- When nobody will own the operational layer. A GraphQL endpoint without depth limits, cost analysis, or resolver-level metrics is a production incident with a schedule. If that work is not staffed, shipping REST is the responsible choice.
What People Get Wrong
"GraphQL is faster than REST."
Neither is inherently faster. GraphQL can cut round trips, which helps on high-latency mobile connections, but a naive resolver layer easily produces more database queries than a hand-tuned REST endpoint. A well-optimised REST endpoint with a CDN in front of it will beat an uncached GraphQL query most of the time.
"GraphQL replaces REST — it is the successor."
They solve different problems and coexist routinely. A common shape is GraphQL for the client-facing aggregation layer and REST or gRPC underneath for service-to-service calls, with file transfer staying on plain HTTP. Treating this as a full migration is how teams end up rewriting an API that was working.
"You do not need API versioning with GraphQL."
You need a different discipline, not less of it. Additive changes are cheap, but removing or changing a field still breaks clients — you just deprecate a field and track its usage instead of cutting a /v2. That requires per-field usage telemetry, which is work you have to build.
"The client can ask for anything, so the backend team is unblocked."
Only for data already in the schema and cheap to resolve. Any new data source still needs a resolver, and clients can trivially write queries the backend never anticipated — deeply nested, wide, or expensive. Unblocking clients means you have moved the constraint into query cost budgets, not removed it.
Why It Was Built This Way
GraphQL came out of a specific constraint: a mobile client on an unreliable network, rendering a screen composed of many related entities, in an app version that will still be installed months after you ship it. All three parts matter. High latency makes round trips the dominant cost, so batching them into one request is worth real complexity. A long client tail means you cannot roll out a new endpoint and assume old callers are gone. And a composed screen means the useful response shape is a client-side rendering decision, not a server-side domain decision.
Given those constraints, moving shape control to the client is the natural answer — and everything awkward about GraphQL follows from that one choice. Once the client picks the shape, the server cannot pre-compute a response, so you get per-field resolvers. Once you have per-field resolvers, you get N+1 by default and need a batching layer. Once the request body determines the work, the URL stops identifying the resource, so HTTP caching and per-route policies stop working and you rebuild them as persisted queries and cost analysis. Once fields fail independently, one HTTP status cannot describe the outcome, so errors move into the body.
What was traded away is the endpoint as a unit of reasoning. In REST, a route is a place to hang a cache policy, a rate limit, a timeout, an alert, a permission check, and a latency graph — free, because the whole HTTP ecosystem agrees on what a URL means. GraphQL gives that up in exchange for client autonomy, and hands you the bill in resolver-level infrastructure you now maintain. That is a good trade when client iteration speed is your bottleneck. It is a bad trade when it is not — which is why the decision should start with counting your clients, not comparing the technologies.
Related Briefs
What to Read Next
- backend for frontend pattern
- GraphQL N+1 problem and dataloader
- REST API versioning strategies
The choice is not about which technology is better designed; it is about where your bottleneck actually is. If frontend teams are queueing behind backend changes to reshape responses for different screens, GraphQL moves that decision to the people who need it and is worth the operational cost. If you have one or two clients, a stable domain, and caching that already works, REST is the correct answer and adopting GraphQL means paying for flexibility you will not use. Count your clients and look at your traces before you look at the technology.
댓글
댓글 쓰기