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.

SituationBetter fit
Many client shapes: web, mobile, partnersGraphQL: one schema, client-selected fields
Rapidly changing UI requirementsGraphQL: a new selection needs no server change
Large, highly related object graphsGraphQL: one round trip instead of several
Simple CRUD with one or two clientsREST: less machinery, and cacheable by URL
File upload or downloadREST: streaming and HTTP semantics are simpler
Public API with a strict contractREST or gRPC: versioning and tooling are well understood
Heavy caching at the edge or in a CDNREST: 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.

Queries, mutations and resolvers Schema and type system

Last refreshed 2026-09-18.