GraphQL Solves a Client Problem
GraphQL moves cost from the client to the server. Often the right trade — but the bill arrives later, and it is paid by whoever operates the API.
GraphQL is usually evaluated as an alternative to REST, which frames it as a matter of taste. It is more useful to see it as a specific trade: the client stops making several round trips and receiving fields it does not need, and in exchange the server takes on query planning, execution limits and a caching problem it did not previously have.
That trade is frequently worth it. It is also frequently made by people who will not be the ones paying the second half.
The problem it genuinely solves
A mobile screen needs a user, their last five orders, each order's line items, and the product name and thumbnail for each item. Against a REST API that is a waterfall — fetch the user, then their orders, then for each order its items — on a connection with 200ms of latency, which is a second of nothing on screen.
The usual REST answers are worse than they look. Adding ?include=orders.items.product reinvents a query language, badly. Building a bespoke endpoint per screen works until you have forty of them and no idea which are still used. Returning everything wastes bytes on exactly the connections that can least afford them.
GraphQL answers this directly: one request, one round trip, exactly the fields the screen declares, and a new screen needs no backend work. For an organisation with several clients — web, iOS, Android, a partner integration — evolving at different speeds, that decoupling is the whole point and it is a genuine win.
The resolver N+1, which is not the database N+1
The structure that makes GraphQL flexible also makes it generate query storms, and it does so by design rather than by accident.
Resolvers execute per field, per object. A query returning 50 orders, each resolving a customer, calls the customer resolver 50 times. Each call is a perfectly reasonable single-row lookup. The client wrote one query and the database received 51.
The fix is batching at the resolver layer. A loader collects the ids requested during a tick and issues one query for all of them:
const customerLoader = new DataLoader(async (ids: readonly string[]) => {
const rows = await db.customers.findMany({ where: { id: { in: [...ids] } } });
const byId = new Map(rows.map((r) => [r.id, r]));
return ids.map((id) => byId.get(id) ?? null); // order must match input
});
const resolvers = {
Order: { customer: (order) => customerLoader.load(order.customerId) },
};Two details that are easy to get wrong. The loader must return results in the same order as the input keys, including nulls for misses — otherwise you silently associate the wrong customer with the wrong order. And the loader must be created per request, not once per process: a module-level loader caches across users, which is a data leak rather than a performance bug.
Worth being precise about how this differs from the N+1 you fix at the database layer. That one is your own code looping. This one is generated by the shape of a query a client wrote, which means you cannot fix it by reading your code — the query that causes it may not exist yet.
Query complexity is a denial-of-service surface
This is the part that gets skipped and it is the one with security consequences. Consider:
query {
orders(first: 100) {
customer {
orders(first: 100) {
customer { orders(first: 100) { id } }
}
}
}
}A short, syntactically valid query that asks for a million objects. In REST, an endpoint's cost is bounded by its implementation. In GraphQL, the client determines the cost, and by default there is no ceiling. An unauthenticated GraphQL endpoint without limits is a resource-exhaustion vector that requires no skill to exploit.
Three controls, all of which you should have before going public:
- Depth limiting. Reject queries nested beyond a fixed depth. Crude, cheap, and stops the recursive case above.
- Complexity scoring. Assign each field a cost, multiply by requested list sizes, reject above a budget. This is the proper control because it prices breadth as well as depth.
- Persisted queries. Clients send a hash of a query registered at build time rather than arbitrary text. The server only executes queries you have seen and approved. This effectively removes the entire class of problem, at the cost of the ad-hoc flexibility — which for a first-party API you probably were not using anyway.
Also disable introspection in production for a non-public API. It is not a security boundary on its own, but publishing your complete schema to anyone who asks is free reconnaissance.
The caching you quietly gave up
REST gets HTTP caching for free. A GET /products/42 is cacheable by the browser, by a CDN, by any proxy in between, keyed by URL, with ETag and conditional requests doing real work.
GraphQL is typically a POST to a single endpoint with the query in the body. Nothing in that chain can cache it. You have traded a mature, free, well-understood layer for one you now implement yourself — normalised client caches, persisted queries with GET so a CDN can participate, or response caching keyed by query hash plus variables plus the viewer's permissions.
That last clause is where it gets genuinely hard: two users sending an identical query may be entitled to different results, so any shared cache has to incorporate authorisation into the key or it will serve one user another user's data. This is a common and serious bug.
The schema is a contract, and contracts are forever
The schema is the best thing about GraphQL. It is typed, introspectable, generates client types, and gives frontend and backend one artifact to agree on.
It is also a public API the moment a second client uses it, with the same discipline that implies: additive changes only, deprecate rather than delete, and no field removed until you have evidence nobody requests it. GraphQL makes that last part tractable, since you can log field usage from the executed query and know exactly what is safe to remove — an advantage REST does not have.
The failure mode to avoid is exposing your database schema as your GraphQL schema. Auto-generated CRUD over every table couples your public contract to your storage layout, so every migration becomes a breaking API change. The schema should describe your domain, and it should be designed rather than derived.
When REST is the better answer
- One client, controlled by you. The flexibility solves a coordination problem you do not have. Build the endpoints the screens need.
- The API is mostly commands, not queries. GraphQL mutations are unremarkable — they are RPC with extra steps. If your surface is predominantly actions rather than reads, the flexible querying earns nothing.
- Caching is central to your performance story. Public, heavily-read, largely uniform data is exactly the case where HTTP caching is doing the heavy lifting, and giving it up to gain field selection is a poor trade.
- File uploads and binary content. Possible, awkward, and a plain endpoint is better.
- The team is small. The operational surface — loaders, complexity limits, cache invalidation, schema governance — is a standing cost, and it is not free.
A reasonable middle path that is undersold: REST for the majority, and GraphQL for the one or two surfaces with genuinely variable, deeply nested read requirements. Nothing requires the whole API to be one thing.
The short version
GraphQL solves a client coordination problem, and it is the right call when you have several clients moving at different speeds. Budget for the server side before you adopt it: per-request batching loaders, a complexity or depth limit before it is publicly reachable, a deliberate answer for caching including authorisation in the key, and a schema designed as a domain contract rather than generated from your tables. If you have one client and a caching story that already works, REST is not the legacy option — it is the correct one.