When GraphQL beats REST, and its pitfalls
An honest comparison with REST, plus the N+1, caching and cost-control problems you must plan for.
When GraphQL beats REST
GraphQL moves field selection from the server to the client. That is a large win when clients disagree about what they need, and unnecessary machinery when they do not.
| Situation | Better fit |
|---|---|
| Many client shapes: web, mobile, partners | GraphQL: one schema, client-selected fields |
| Rapidly changing UI requirements | GraphQL: a new selection needs no server change |
| Large, highly related object graphs | GraphQL: one round trip instead of several |
| Simple CRUD with one or two clients | REST: less machinery, and cacheable by URL |
| File upload or download | REST: streaming and HTTP semantics are simpler |
| Public API with a strict contract | REST or gRPC: versioning and tooling are well understood |
| Heavy caching at the edge or in a CDN | REST: GET is trivially cacheable, a POST document is not |
- Over-fetching disappears: a mobile client asks for two fields and receives exactly those two.
- Under-fetching disappears: nested selections replace a chain of follow-up requests.
- The schema is a contract that generates client types and serves as living documentation.
- One endpoint also means one place to observe, rate-limit and protect, instead of a growing set of routes.
The pitfalls, and how to handle them
// N+1: every post triggers its own author query
const slow = {
Post: { author: (post, _args, { db }) => db.user.findById(post.authorId) },
};
// DataLoader batches the ids requested in the same tick into one query
import DataLoader from "dataloader";
const userLoader = new DataLoader(async (ids) => {
const users = await db.user.findByIds(ids);
const byId = new Map(users.map((u) => [u.id, u]));
return ids.map((id) => byId.get(id) ?? null); // order must match the input
});
const fast = {
Post: { author: (post, _args, { loaders }) => loaders.user.load(post.authorId) },
};- N+1 queries: batch with DataLoader, or resolve a whole subtree with one joined query. This is the pitfall that bites first and hardest.
- Caching: requests are POSTs carrying a client-chosen shape, so plain HTTP caching does not apply. Use persisted queries, response caching keyed on document plus variables, and per-object caching in resolvers.
- Cost control: a deeply nested selection can be arbitrarily expensive. Add query depth and complexity limits, plus timeouts, before exposing the endpoint.
- Persisted query allowlists: in production, accept only known documents so an arbitrary query can never reach your resolvers.
- Observability: instrument per-resolver timing. The HTTP request looks fast while one field is quietly slow.
- Error handling: partial success is the norm, so decide which failures should null a field and which should fail the request.
⚠️
Disable introspection and the playground in production, and always cap query depth and complexity. Without those caps, one crafted query can exhaust your database — a denial of service written in the query language.
FAQ
Is GraphQL always faster?
No. It removes round trips and over-fetching, but adds parsing, validation and per-field resolution overhead. For one simple resource, a REST GET with HTTP caching usually wins.
How do I cache GraphQL responses?
Persist the documents, then cache on the document plus its variables, and cache individual objects inside resolvers with a documented invalidation rule.
Related
Queries, mutations and resolvers Schema and type system
Last refreshed 2026-09-18.