One Query for the Fields You Render: What GraphQL Actually Fixes
query HomePage($id: ID!) {
me { name avatarUrl notificationCount }
featured { id title price { amount currency } }
orders(limit: 5) { id status placedAt }
}
Against a REST API this screen costs three round trips — GET /me, GET /featured, GET /orders?limit=5 — each returning whole rows: forty fields when the view renders five, including internal columns you never meant to expose. Over a 200 ms mobile round trip, three sequential fetches are 600 ms before first paint, and the payloads carry bytes the radio will discard. GraphQL's offer is specific: clients request exactly the fields they need in one round trip, and the response shape mirrors the selection.
That addresses two distinct REST failure modes. Overfetching is receiving fields you never render. Underfetching is needing three endpoints to compose one view — and the usual REST repair is a server-side aggregate endpoint (/dashboard, /mobile/home) that exists only because one client wanted a bundle. Those snowflakes multiply until the API is really a per-screen interface with REST's costume on.
The trade-off is where the freedom lands. In REST you may change a representation for one consumer version; in GraphQL the schema is a contract with every client simultaneously, so evolution must be additive: add types and fields freely, mark old ones @deprecated(reason: "use price.amount"), and never repurpose a field's semantics. This is the same discipline protobuf follows when it adds fields with fresh numbers and never renumbers — see gRPC and Protobuf Contracts — except GraphQL carries a human-facing deprecation channel instead of a compiler error.
Introspection is the reason tooling is good: clients can generate types from the live schema, and explorers let developers compose queries against real fields. It is also an attack surface — introspection in production tells an attacker your entire internal shape — so most teams disable it outside development, and publish the schema to consumers as a versioned artifact instead.
Be honest about what you bought. One endpoint means one place to rate-limit, authenticate, and observe — but also one place where a badly designed resolver can hammer the database, which is the next problem. Flexible queries push complexity out of client coordination and into server governance: the N+1 explosion, query cost control, and caching. A useful interview framing: GraphQL wins when many clients with different screens share one domain; it loses when your reads are simple, uniform, and cache-heavy, because a public catalog served from a CDN is easier as GET /products than as a POST whose body the cache cannot see.
Typing flows both ways. Because introspection exposes the schema, client codegen produces TypeScript types or Swift models matching the server exactly, so a renamed field becomes a build error in the app instead of a runtime crash — the same guarantee a generated REST SDK gives, now applied to query results. The guarantee holds only if clients regenerate against a published schema artifact, which is why schema publication belongs in the release process instead of living solely on the running server.
Mutations and subscriptions complete the language: mutations run in the caller's declared order and must be side-effecting, while subscriptions push server-initiated updates over WebSocket or SSE for live feeds. Each adds an operational surface — a connection lifecycle to reconnect, a fan-out to bound — so treat subscriptions as streaming infrastructure and ask the same questions you would of any stream: who buffers, who reconnects, what happens when the consumer stalls.
Practical migration advice for teams with an existing REST estate: keep cache-heavy reads on REST where the CDN already wins, and move composed, screen-shaped views onto the graph first. Incremental adoption like that is how GraphQL earns its place instead of arriving as a rewrite.
100 Orders, 201 Queries: Killing the N+1 With a DataLoader
The query above looks cheap. The server disagrees:
{
orders(limit: 100) {
id
customer { name }
items { sku }
}
}
Each resolver runs independently: one query fetches the 100 orders, then the customer resolver fires once per order — 100 more queries — and items fires 100 times again. That is 201 database round trips to serve a single HTTP request. Nested one level deeper (customer's city, item's supplier) and the count goes multiplicative. Every nested list in a GraphQL schema is an N+1 waiting for a naive resolver.
The standard fix is a dataloader: an object that collects keys during the current event-loop tick, deduplicates them, and issues one batched fetch per tick.
const customerLoader = new DataLoader(async (ids) => {
const rows = await db.customers.findMany({ where: { id: in(ids) } }); // ONE query
return ids.map((id) => rows.get(id) ?? null); // order must match input
});
const resolvers = {
Order: {
customer: (order, _args, ctx) => ctx.loaders.customer.load(order.customerId),
},
};
Because resolvers for sibling orders run within the same tick, their load calls collapse into WHERE id IN (...). The 100 customer lookups become one query; total drops from 201 to roughly 3.
Rules that keep it correct:
- Construct loaders per request. A module-level loader caches across users; a row cached for user A can be served to user B — an authorization leak disguised as a performance optimization.
- The cache is request-scoped, so it never spans requests; write invalidation where the data changes (the source of truth), not in the loader.
- Chunk
INlists: PostgreSQL caps bound parameters around 65,535, so a 100k-id batch must split. - Batching cannot express per-parent
ORDER BY/LIMIT—items(limit: 3)per order still needs a lateral join or a different data access, because one flatINquery cannot return three rows per parent.
Detection matters as much as the fix: log resolver timings, alert when one request issues more than a handful of SQL statements, and profile with the engine's query log rather than assuming the graph is fine because the page eventually rendered.
A production setup adds structure around the loader: build a fresh loader map per request in middleware — keyed off the authenticated context — expose it to resolvers through the request context, and unit-test resolvers against a fake loader returning canned batches, so N+1 regressions surface in tests instead of in production latency. For paginated list fields, register the declared page size in the complexity score so first: 1000 costs what it actually costs.
Note the cost you accept by making loaders the default for every association: batching waits for the current tick to close, adding microseconds — trivial next to the round trips saved, and the reason you do not debate per-case whether to batch.
Measurement closes the loop. An alert such as more than 10 SQL statements per GraphQL request fires the first time someone adds a resolver without a loader, within days of release rather than during a postmortem. Pair it with per-resolver timing logs and the slow path names itself.
Batching fixes data loading, not adversarial input. An attacker does not need N+1 to hurt you — aliases do it directly: { a1: orders { ... } a2: orders { ... } ... } repeats an expensive field a thousand times in one query. Guard the query itself: depth limits (reject nesting past ~7), complexity scoring that weights each list field by its declared page size and counts aliases, persisted queries in production so only pre-approved operation hashes reach resolvers, a per-query timeout, and introspection off. Each control is cheap; together they turn query cost from a function of client goodwill into a bounded number.
Why the CDN Will Not Cache POST /graphql: Caching, Errors, and When to Say No
A REST GET /orders/42 caches beautifully: the URL is the key, so the CDN, the browser, and ETag revalidation all work with zero application code. GraphQL almost always arrives as POST /graphql with the query in the body — and HTTP caches ignore request bodies, so two different queries share one URL and share one cache entry. Default GraphQL is effectively uncacheable, which is the trade-off people discover in production rather than in design review.
Three techniques restore caching, at different layers:
| Technique | Where it works | What it keys on |
|---|---|---|
| Automatic persisted queries | CDN and browser | operation hash sent as ?hash=..., enabling GET with a stable URL |
@cacheControl(maxAge, scope) hints |
app/CDN via extensions | per-field hints merged into a response max-age |
| Normalized field cache in the server | process | type + id as the key, like a REST entity cache |
Persisted queries are the strongest: the client sends only a hash, the server maps it to a pre-registered document, the URL becomes cacheable again, and unregistered operations are rejected — which doubles as your production allowlist. The catch is coordination: every client release must register its queries before shipping, and the cache key must include any user-varying context. Cache poisoning is the failure mode: a response personalized for user A served to user B because a shared edge stored it without Cache-Control: private or an Authorization-aware key. Anything user-specific stays private, always.
Error semantics are the second production surprise. GraphQL returns HTTP 200 with an errors array for resolver failures; only a syntactically invalid query gets 400. Two consequences: monitoring that counts 5xx will not see GraphQL breakage at all, and an edge cache will happily store a 200 error payload as if it were the page. Fix both explicitly — parse errors in your SLO dashboards, and set no-store on any response where a top-level resolver failed.
Step back and compare the three contracts this phase has covered:
| Concern | REST | gRPC | GraphQL |
|---|---|---|---|
| Caching | free on GET URLs | none (internal, binary) | needs persisted queries or field cache |
| Typing | schema docs + codegen | compiled, strict | introspected schema, validated at query time |
| Flexibility | fixed endpoints | fixed RPCs | client-shaped queries |
| Failure signal | HTTP status | canonical status codes | 200 + errors array |
| Evolution | version placement | field numbers | additive fields + @deprecated |
Choose GraphQL when many heterogeneous clients share one domain and screens evolve faster than endpoints should; choose REST for cache-heavy public reads, file transfer, and simple CRUD; choose gRPC for internal chatty calls with deadlines. And once every client type hits one edge, the question becomes who owns that edge — which is exactly API Gateways and Backend-for-Frontend.
Wire the layers together at that edge: a gateway in front of /graphql can enforce the persisted-query allowlist, validate the token, rate-limit per key, and apply a query timeout before any resolver runs — a clean division of labor between edge policy and graph governance. The gateway still cannot judge resolver cost, which is exactly why both layers exist rather than one.
Remember the partial-failure rule when writing runbooks: a top-level null with an errors entry means the whole query failed, while a non-null violation propagates the null upward. Clients must therefore treat some fields present, some errored as a normal state to render around, not as an exception — and monitoring must read the errors array to know the API is unhealthy at all.
Quiz
1. A mobile home screen fires three REST calls that each return whole rows, then renders five fields. Which claim about GraphQL is accurate?
2. Your server logs show 1 `orders` query plus 100 `customers` lookups for a single GraphQL request. What is the cause and the standard fix?
3. Why can a shared CDN not cache two different GraphQL queries sent as POST /graphql?
4. An attacker sends a shallow query that repeats an expensive list field 1,000 times using aliases. Which control directly bounds that cost?
Flashcards
Question
What are overfetching and underfetching, and how does GraphQL remove them?
Click to reveal answer
Answer
Overfetching: the server returns fields the view never renders. Underfetching: one screen needs several endpoints, forcing multiple round trips or bespoke aggregate endpoints. A GraphQL selection declares exactly the fields for the screen and resolves them in a single request, so the response shape mirrors what the client renders.
Question
What is the N+1 problem in GraphQL and how does a dataloader fix it?
Click to reveal answer
Answer
Each nested resolver runs once per parent row: 1 order query + 100 customer lookups + 100 item lookups = 201 round trips for one HTTP request. A dataloader collects keys during the current tick, dedupes them, and issues one batched query (`WHERE id IN (...)`) per level — turning 201 statements into roughly 3.
Question
Why must a dataloader be constructed per request, and what else limits batching?
Click to reveal answer
Answer
A shared loader's cache outlives the request, so a row cached for one user can be served to another — an authorization leak, not just staleness. Per-request scoping also bounds memory. Beyond that: chunk `IN` lists under the database parameter limit (~65,535 on PostgreSQL), and remember batching cannot express per-parent ORDER BY/LIMIT — those need lateral joins or a different access path.
Question
Name four cost controls for hostile or accidental expensive GraphQL queries.
Click to reveal answer
Answer
Depth limits (~7 levels), complexity scoring that weights list fields by page size and counts aliases, persisted queries as a production allowlist of operation hashes, and a per-query timeout — plus introspection disabled in production. Each is cheap; together they cap query cost regardless of what the client writes.
Question
Why is GraphQL hard to cache over HTTP, and what are the three standard repairs?
Click to reveal answer
Answer
The query lives in the POST body while caches key on method + URL, so distinct queries collide. Repairs: automatic persisted queries (hash in the query string → stable, cacheable URL and an allowlist), `@cacheControl` max-age hints surfaced to CDN/app layers, and a normalized server-side field cache keyed by type + id. User-specific responses must stay `Cache-Control: private` to avoid poisoning shared caches.
Revision Notes
Key Takeaways
- 1. GraphQL's win is a field-exact response in one round trip; its price is that the schema becomes a simultaneous contract with every client, so evolution is additive with @deprecated only
- 2. Every nested list is an N+1 risk — sibling resolvers fire per parent row, and a request-scoped dataloader collapses them into one batched query per level
- 3. Loader caches must be per request: a cross-request cache turns a performance optimization into a cross-user data leak
- 4. Query cost must be bounded server-side — depth limits, complexity scoring that counts aliases, persisted-query allowlists, and timeouts — because client goodwill is not a control
- 5. POST bodies are invisible to HTTP caches, so cacheability is restored with persisted-query hashes and cache hints, while 200-with-errors responses must be excluded from both SLOs and CDNs
Interview Tips
- • "Explain the N+1 problem and how you fix it" — give the 1 + N + N×M count from a concrete nested query, then dataloader mechanics: batch per tick, dedupe, request-scoped cache; add the lateral-join limit to show you know batching's boundary
- • "How do you secure a public GraphQL endpoint?" — depth and complexity limits, persisted-query allowlist, introspection off in production, per-query timeout, then authorization at the resolver and field level, not just at the gateway
- • "Why is GraphQL harder to cache than REST?" — bodies are invisible to HTTP cache keys; answer with persisted queries (hash in URL) and cache-control hints, plus the private/public split for personalized data
- • "When would you refuse GraphQL?" — cache-heavy public reads, file transfer, simple CRUD, or teams without the governance discipline; naming a concrete project where REST wins proves judgment over fashion
Cheat Sheet
GraphQL Trade-offs
The win: one round trip, exactly the selected fields — removes overfetching (unused fields) and underfetching (aggregate endpoints per screen).
The costs and controls:
| Problem | Symptom | Control |
|---|---|---|
| N+1 resolvers | 1 + N + N×M SQL statements per request | request-scoped dataloader, batched IN per tick |
| Alias amplification | shallow query, 1000× expensive list | complexity scoring (weights × page size, counts aliases) |
| Deep nesting | recursion through relations | depth limit ~7 |
| Arbitrary operations | attackers probe at will | persisted queries (hash allowlist), introspection off |
| Slow query | one request pins the DB | per-query timeout |
Dataloader rules: construct per request (shared cache = cross-user leak); dedupe and batch per tick; chunk IN past ~65k params; per-parent ORDER/LIMIT needs a lateral join, not batching.
Caching: POST bodies are invisible to HTTP caches → automatic persisted queries put the operation hash in the URL; @cacheControl hints set max-age; normalized field cache keys on type + id. Personalized responses: Cache-Control: private, never shared-edge.
Errors: resolver failures return HTTP 200 + errors array → dashboards must parse errors, and failed responses must be no-store or the CDN caches them.
Pick by fit: REST for cache-heavy public reads, files, simple CRUD · gRPC for internal chatty deadline-aware calls · GraphQL for many heterogeneous clients over one domain.