GraphQL: When It Earns Its Keep
You’re building a profile screen for a phone app. It shows a person’s name and avatar, their three most recent posts, and the like count on each post. Nothing exotic.
With the REST backend you shipped last month, that screen costs three trips to the server. GET /users/42 for the name and avatar. Then GET /users/42/posts?last=3 for the posts, which you can’t fire until the first response tells you the user exists. Then, because the posts endpoint returns bodies but not like totals, a third call to tally likes. Each request waits on the one before it. On hotel wifi with a 300ms round trip, your user stares at a spinner for most of a second before a single pixel paints.
Now look at what came back. The user endpoint returned forty fields. You rendered two. Billing address, notification settings, a base64 avatar you already had cached: all of it rode across the wire for nothing.
That one screen is a clean demonstration of the two problems GraphQL was built to kill. It’s also, as you’ll see, a fair bit of new machinery to solve them. So the honest question isn’t “is GraphQL good,” it’s “is your situation the one where it pays for itself.” Most of the time it isn’t. Sometimes it clearly is. Let’s earn that judgement.
The two ways a fixed endpoint hurts
A REST endpoint returns a fixed shape. The server decided, ahead of time, exactly which fields GET /users/42 hands back. That single decision causes both of the profile screen’s problems, pulling in opposite directions.
Over-fetching is when an endpoint gives you more than you render. The forty-field user object. You pay for the bytes on the wire, the JSON parse, and the memory, every time, for fields nobody looks at.
Under-fetching is the reverse: one endpoint doesn’t have everything a screen needs, so you make more round trips to stitch the rest together. The separate posts call, then the separate likes call. Each round trip is a full network latency you can’t get back.
The cruel part is that fixing one makes the other worse. Trim the user endpoint down to two fields and now some other screen has to make an extra call for the fields you removed. Fatten it up to cover every screen and everyone over-fetches. A fixed shape can’t be right for every caller at once.
That right-hand column is the pitch, and it’s a real one. One request, the exact fields, one trip. Hold onto it, because the rest of this article is the invoice.
One endpoint, one typed schema
GraphQL flips who decides the response shape. Instead of the server baking a shape into each URL, the server publishes a typed schema of what data exists, and the client sends a query naming the fields it wants. There is usually just one URL, and clients POST their query to it.
The schema is written in a small type language. Here’s enough of one to serve that profile screen:
type User {
id: ID!
name: String!
avatarUrl: String
posts(last: Int = 5): [Post!]!
}
type Post {
id: ID!
title: String!
likeCount: Int!
}
type Query {
user(id: ID!): User
}
A ! means non-null. [Post!]! is a non-null list of non-null posts, so posts never returns null and never contains a null. The special Query type is the entry point: its fields are the queries a client is allowed to start from. Fields can take arguments, like posts(last: Int = 5) with a default.
A client asks for a subset of that graph, nesting exactly as deep as it needs:
query ProfileScreen {
user(id: "42") {
name
avatarUrl
posts(last: 3) {
title
likeCount
}
}
}
And the response comes back mirroring the query, key for key:
{
"data": {
"user": {
"name": "Ada Lovelace",
"avatarUrl": "https://cdn.example.com/ada.png",
"posts": [
{ "title": "On compilers", "likeCount": 12 },
{ "title": "Coffee counts", "likeCount": 3 }
]
}
}
}
No fields you didn’t ask for. No second request. The shape of what you get is the shape of what you asked.
Resolvers do the actual work
The schema says what fields exist. Resolvers say where each field’s value comes from. A resolver is just a function attached to a field, and the server runs one per field it needs to produce:
const resolvers = {
Query: {
// parameterised, always: args.id is untrusted input
user: (_parent, args, ctx) => ctx.db.users.byId(args.id),
},
User: {
posts: (user, args, ctx) => ctx.db.posts.byAuthor(user.id, args.last),
},
Post: {
likeCount: (post, _args, ctx) => ctx.db.likes.countFor(post.id),
},
};
Each resolver receives four things: the parent value (whatever the field above it returned), the args for this field, a per-request context (a great place to stash the authenticated user and database handles), and an info object describing the query. The engine walks the query top down. It runs Query.user, hands that user to User.posts as its parent, and hands each post to Post.likeCount. A field with no explicit resolver falls back to reading the property of the same name off its parent, which is why name needs no code: the user object already has a name.
That is the whole model. A typed schema, one endpoint, a query that selects fields, and a resolver per field. Everything else here is a consequence of that model, and the consequences are where GraphQL gets expensive.
Try the selection idea directly. Tick fields on a mock User and watch the response grow and shrink, with a running byte count against the fixed REST payload that always sends everything:
The N+1 problem is the shape, not a bug
Here comes the first bill, and it’s the one that surprises people who assumed GraphQL is magically efficient.
Change the query to list ten users, each with their posts. Watch the resolvers fire. Query.users runs once and returns ten users. Then, for every user, the engine runs User.posts. That resolver contains a database call. So it runs ten times, once per user, each firing its own query. One query for the list, then N more for the children: eleven database round trips for a single request. Ask for the author of each post as well and it compounds into the hundreds.
This is the N+1 problem, and in GraphQL it is structural. A resolver is scoped to a single field on a single object. User.posts running for Ada has no idea that User.posts is also about to run for nine other users. It can’t batch what it can’t see. The naive, obvious resolver fires one query per node in the tree.
The standard fix is a batching loader, and the reference implementation everyone reaches for is DataLoader. The trick is timing. Instead of querying inside the resolver, you call loader.load(key), which returns a promise but does nothing yet. DataLoader collects every key requested during the current tick of the event loop, then fires your batch function once with the whole array. (If you’re fuzzy on why all those load calls land in the same tick, the event loop and microtask queue articles are the background.)
// one loader instance, created fresh per request
const postsByAuthor = new DataLoader(async (authorIds) => {
// authorIds arrives as [1, 2, 3, ...], one parameterised query
const rows = await db.posts.byAuthorIds(authorIds);
// CRITICAL: return one entry per key, in the same order as authorIds
return authorIds.map((id) => rows.filter((r) => r.authorId === id));
});
const resolvers = {
User: {
posts: (user) => postsByAuthor.load(user.id),
},
};
Now the ten load calls collapse into a single ... WHERE author_id IN (1, 2, ..., 10). Eleven queries become two. Two rules bite people hard here, so burn them in:
- The batch function must return an array the same length and order as the keys. DataLoader matches results to keys by position. Get the order wrong and you’ll cheerfully hand one user’s posts to another user. That’s a data-leak bug, not a crash, so tests won’t always catch it.
- Create a new loader per request. The per-request cache (
load(5)twice hits the batch once) is a feature only within one request. Share a loader across requests and you serve stale data and, worse, leak one user’s data into another user’s response.
N+1 isn’t unique to GraphQL. Any lazy per-item load in any framework can trigger it. GraphQL just makes the per-field resolver the default path, so batching stops being a nice optimization and becomes the price of admission.
Caching: the thing you quietly gave up
REST rides on HTTP, and HTTP has caching built into its bones. GET /users/42 is a URL, and every layer between your user and your database already knows how to cache a URL: the browser, a CDN, a corporate proxy, a reverse proxy in front of your app. Set a Cache-Control header, add an ETag, and a repeat request can be served without your server ever waking up. That is the single cheapest performance win on the web, and you get it for free.
GraphQL throws it away by default. Every request is a POST to /graphql with the query in the body, and nobody caches POST bodies. One URL, infinite response shapes, no cache key anyone respects. The moment you go GraphQL, HTTP caching is off the table.
You can buy two replacements, and neither is free:
- A normalized client cache. Mature clients (Apollo Client, urql, Relay) parse each response into a flat store keyed by type and id, so a
User:42fetched by one query is reused by the next. It’s powerful, and it’s a cache you now run, with its own invalidation problems. - Persisted queries over GET. Register the query text on the server, address it by a hash in a
GETURL, and a CDN can cache it again. We’ll get to how this works below.
Every query is a potential denial of service
Because the client composes the query, the client can compose a ruinously expensive one. Cyclic relationships make it trivial. If a User has friends and a friend is a User, this is a perfectly legal query:
{
user(id: "1") {
friends {
friends {
friends {
friends { name }
}
}
}
}
}
Every level multiplies the work. Eight levels deep on a social graph where the average node has a hundred friends is on the order of a hundred to the eighth power of resolver invocations, all from a request that fits in a tweet. You didn’t write a bug. You published an endpoint that lets a stranger ask your database to do an unbounded amount of work. This is a catalogued attack class, and it lands as real CVEs against shipped software: GitLab patched more than one GraphQL denial-of-service issue in 2025 alone.
You want two defenses, and you want both:
- Depth limiting. Reject any query nested deeper than some ceiling (8 is a common starting point) before executing it. It’s cheap and it catches the cyclic bombs outright.
- Cost analysis. Give every field a cost, where a list field taking
first: 100costs more than a plain scalar, give each request a budget, sum the query’s cost by walking its syntax tree, and reject anything over budget before a single resolver runs.
Stack rate limiting, per-request timeouts, and pagination caps on top. And introspection, the feature that lets a client download your whole schema: the current guidance has softened from “always disable it” to “keep it on behind auth for your own tooling, turn it off at the public edge.” The through-line is that a GraphQL endpoint without cost limits is a loaded gun pointed at your own database. This is not optional hardening. It’s part of shipping the thing at all. The broader picture lives in security in depth.
Authorization moves into every room
In a REST app, authorization often lives at the door. Middleware on GET /admin/* checks a role and you’re done, because the route maps cleanly to the resource. (That layered-middleware pattern is its own topic in the middleware onion.)
GraphQL has one door and one route. A single query can reach a dozen types across your whole domain, so “is this caller allowed to see this?” cannot live at the endpoint. It sinks down into the resolvers. User.email checks whether the viewer is that user or an admin. Post.draft checks ownership. Authorization becomes field-level, which is genuinely more precise, and also many more places to get it wrong. Every new field is a new decision someone has to remember to make. This is exactly why the per-request context carrying the authenticated user gets threaded into every resolver.
Errors come back with a 200
Here’s one that trips up everybody’s monitoring. In HTTP land you lean on status codes: 404 gone, 403 denied, 500 broke. GraphQL, by default, answers almost everything with 200 OK and puts the problems in an errors array sitting next to data:
{
"data": { "user": { "name": "Ada", "posts": null } },
"errors": [
{ "message": "Not authorized to read posts", "path": ["user", "posts"] }
]
}
The reason is partial success. The name resolver worked, the posts resolver threw, so you get the name and an error about the posts, with a null bubbling up to the nearest nullable field. There is no single HTTP status that honestly describes “half of this request succeeded.” So the response carries both, and you check for failure by inspecting errors, not the status line.
The transport is slowly catching up. The GraphQL-over-HTTP specification, whose draft has been advancing through 2026, defines a newer response media type, application/graphql-response+json, that lets a server use real non-2xx status codes when the whole request is malformed or invalid (a request error) as opposed to a field failing mid-execution (a field error). Support is uneven across servers, so don’t assume it. Assume 200 plus an errors array and you’ll be right most of the time.
Persisted queries, and the security upgrade hiding inside them
Two different ideas share this name, and people conflate them constantly. Getting the difference straight is worth a paragraph each, because one is a speed tweak and the other is a serious security control.
Automatic Persisted Queries (APQ) is a bandwidth optimization. The client hashes its query string with SHA-256 and sends just the 64-character hash. If the server has seen that hash, it runs the stored query. If not, it replies “unknown,” the client resends the full query plus its hash exactly once, and the server remembers it. After that first miss, everyone sends tiny hashes instead of kilobyte query strings, and because a hash fits in a GET URL, a CDN can cache the response. Pure performance, nothing more.
Persisted operations, also called trusted documents, look identical on the wire but flip the default. At build time you extract every query your app actually uses, register that exact set on the server as an allowlist, and configure the server to reject anything that isn’t on the list. Now an attacker cannot submit that friends-of-friends bomb, because it is not a known document. The entire denial-of-service surface from two sections ago mostly evaporates, and your payloads shrink as a bonus.
Where GraphQL genuinely earns its keep
Everything above is the bill. Here is when it’s worth paying.
Many clients, different needs. A web app, an iOS app, an Android app, and a partner integration all want different slices of the same data. With REST you either ship one fat endpoint that over-fetches for everybody, or you sprout a rash of bespoke endpoints (/mobile/profile, /web/profile-v2) that rot at different rates. GraphQL lets each client ask for its own shape against one schema. This was the original problem it was built to solve, at a company drowning in mobile clients.
Deeply related, graph-shaped data. When your domain genuinely is a graph, users and posts and comments and likes and follows all cross-referencing each other, and your screens routinely need three or four hops, expressing that as one typed query beats hand-orchestrating six REST calls and stitching the results.
One graph across a large org. Federation lets many teams own slices of a single schema that composes into one endpoint. For a big company that’s a real organizing win: one place to discover what data exists, one type system, teams shipping independently. It is also a substantial pile of infrastructure, which is the point. It pays off precisely at the scale where that infrastructure is justified, and it’s dead weight below it.
When plain REST is the right answer, which is often
Reach for REST, and feel no guilt about it, when:
- One client, one team. If the same people write the API and its only consumer, the client-tailored-queries benefit rounds to zero and you’re paying the entire bill for a feature you never use.
- You depend on HTTP caching. A read-heavy public API that lives on CDN cache hits is the exact place GraphQL’s caching story hurts most.
- The surface is small and CRUD-shaped. A dozen endpoints over a few resources don’t need a graph. Give them clean REST and go home.
- Files, uploads, downloads, streaming. GraphQL is bad at binary. Serve those over plain HTTP.
- You value being able to curl it. A REST endpoint is debuggable with tools every engineer already has open. See a URL, hit it, read the JSON.
The honest framing: GraphQL trades away HTTP’s built-in machinery (free caching, meaningful status codes, per-route auth, plain curl-ability) in exchange for client-driven flexibility. If you aren’t actively cashing in that flexibility with multiple demanding clients or a truly graph-shaped domain, you’re paying for it and getting nothing back. Adopt it because a specific pain is actively hurting you, like three round trips per screen across four client apps, not because it’s the more interesting box to check.
A note on where the spec stands
The concepts here are evergreen. Schema, resolvers, over-fetching and under-fetching, N+1, batching, cost limiting: none of it has meaningfully shifted in years, and none of it is going to. The paperwork underneath keeps moving at a gentle pace. The GraphQL specification reached a dated formal release in September 2025 and now ships on a steady cadence with a rolling working draft, and the GraphQL-over-HTTP companion spec, which pins down transport details like media types and status codes, has its own draft advancing through 2026. Server libraries in the JavaScript world churn faster than either. Learn the shapes laid out here and the specific library becomes a footnote you can swap.
Summary
- A fixed REST endpoint causes over-fetching (fields you never render) and under-fetching (extra round trips to stitch a screen together), pulling against each other so no single shape is right for every caller.
- GraphQL exposes a typed schema at one endpoint. The client sends a query naming exact fields, and the response mirrors that shape. Each field is produced by a resolver function.
- N+1 is structural: a per-field resolver can’t see its siblings, so lists fire one child query per item. A per-request DataLoader batches keys within a tick into one query. Match result order to keys, and never share a loader across requests.
- HTTP caching is the big hidden cost. POST-to-one-URL kills CDN and browser caching by default. You buy it back with a normalized client cache or persisted queries over GET.
- Cost and depth limiting are mandatory. Client-composed queries plus cyclic types make denial of service trivial. Limit depth, budget cost, and reject before executing. Real CVEs prove people skip this.
- Auth moves into resolvers and errors return 200 with an
errorsarray, so field-level checks and body-parsing monitoring replace route middleware and status-code dashboards. - APQ is a bandwidth hash trick; trusted documents are a security allowlist that also kills the DoS surface. Same wire format, opposite defaults.
- Reach for GraphQL with many different clients, deeply linked data, or one graph across a big org. Reach for REST for a single client, small CRUD surfaces, heavy caching, files, or plain debuggability. REST is still the right default, and choosing it is not a failure of nerve.