What GraphQL Costs
The flexibility GraphQL gives clients is exposure the server must manage: resolver N+1, arbitrary expensive queries, per-field authorization, lost HTTP caching, invisible operations. Batching, cost limits, persisted queries and operation-level telemetry are the price — budget it before adopting the schema.
Frame the contract
API design starts with a consumer, a design question and a guarantee — never with a URL.
N+1 moves to the server and multiplies
The naive resolver model is elegant and quadratic. users { orders { items } } resolves users with one query, then orders once *per user*, then items once *per order*. A hundred users with ten orders each is 1 + 100 + 1,000 database round trips for a query that looks like a single request. REST had the same problem on the client side, where it was visible as many HTTP calls; GraphQL moved it behind one POST where only the database notices (API Performance: The Levers You Actually Own).
The fix is batching with a per-request loader: resolvers do not fetch immediately, they enqueue keys, and at the end of each execution tick the loader issues one WHERE user_id IN (…) for all pending keys and distributes results. The 1,101 queries become three. The loader also memoizes within the request, so the same user reached by two paths is fetched once. This is not an optimization to add later — a GraphQL server without batching is a denial-of-service endpoint with a schema (Joins: INNER, LEFT, RIGHT, FULL, CROSS, SELF for what the batched query becomes).
1const resolvers = {2 Query: { users: () => db.users.findMany({ take: 100 }) },3 User: { orders: (user) => db.orders.findMany({ where: { userId: user.id } }) }, // ×1004 Order: { items: (order) => db.items.findMany({ where: { orderId: order.id } }) }, // ×10005}6// users { orders { items } } → 1 + 100 + 1000 round trips1const ordersByUser = new DataLoader(async (userIds) => {2 const rows = await db.orders.findMany({ where: { userId: { in: userIds } } })3 return userIds.map((id) => rows.filter((r) => r.userId === id))4})5const itemsByOrder = new DataLoader(async (orderIds) => { /* one IN query */ })6 7const resolvers = {8 Query: { users: () => db.users.findMany({ take: 100 }) },9 User: { orders: (user, _, ctx) => ctx.ordersByUser.load(user.id) },10 Order: { items: (order, _, ctx) => ctx.itemsByOrder.load(order.id) },11}12// users { orders { items } } → 3 round trips; loaders are per-requestThe schema and the client query did not change. Batching is a resolver-layer discipline the server owes every field that resolves a relationship — and a per-request loader keeps memoization from leaking data between users.
Arbitrary queries need arbitrary limits
Batching bounds the multiplier; it does not bound the query. A client — or an attacker with an introspected schema — can ask for users(first: 1000) { friends(first: 1000) { friends(first: 1000) { … } } }, and the loader will dutifully batch a billion rows. GraphQL servers need explicit cost control: a depth limit (reject nesting past N), a complexity budget (each field costs points, list fields multiply by their first argument, the query must fit a budget), and a per-client rate limit denominated in cost points rather than requests (The Rate-Limit Contract). Every list field must take a bounded first argument — unbounded lists in a schema are Unbounded Collections: The Anti-Pattern With a Fuse with recursion.
For clients you control, persisted queries close the surface entirely: the client registers its operations at build time, the server accepts only registered ids, and arbitrary queries are refused in production. Partners and browsers you do not control get the cost budget and the rate limit. Introspection itself is often disabled or authenticated on public endpoints — not as security by obscurity, but because it is a free schema map for anyone probing for expensive paths.
| Control | Bounds | Misses | Cost to run |
|---|---|---|---|
| Per-request batching (loaders) | N+1 multiplication | Wide queries with large first | Loader per relationship; per-request context |
| Depth limit | Runaway nesting | Wide, shallow queries | One number; false positives on legitimately deep screens |
| Complexity budget | Wide × deep queries by points | Mis-estimated field costs | Cost annotations per field; tuning |
| Cost-based rate limit | Sustained expensive traffic per client | One-shot spikes under the limit | Point accounting per client |
| Persisted queries | Everything for first-party clients | Third parties you cannot register | Build-time registration pipeline |
| Timeouts + result size caps | Anything that slipped through | Wasted work already done | Cancellation plumbing in resolvers |
Authorization, caching and observability are relocated, not free
REST authorizes per endpoint; GraphQL must authorize per field and per parent, because User.email is reachable through me, order.customer, review.author and any path added later. The rule has to live in the resolver layer or a directive (@auth(requires: OWNER)), and schema changes need a security review question REST rarely asks: "which new paths reach sensitive fields?" (Authorization Design in the Contract). Multi-tenant graphs add tenant scoping to every loader key.
HTTP caching is gone — every query is a POST to /graphql — so caching moves to resolvers (per-entity caches keyed by id) and to clients (normalized caches keyed by type and id). Both are more work than a Cache-Control header and neither helps a CDN (Caching as a Contract Clause). Observability moves from URL to operation name: every query should carry a name, metrics must be reported per name and per resolver, and errors must be read from the extensions field because the HTTP status is always 200 (API Metrics: Rate, Errors, Duration, Sizes, The Error Model: Structure Over Apology). A GraphQL API on a REST dashboard shows one green endpoint while it burns.
[ ] Batched loader for every relationship field; per-request instances [ ] Depth limit + complexity budget; cost annotations on list fields [ ] `first` argument required and capped on every list field [ ] Persisted queries for first-party clients; introspection gated in prod [ ] Cost-denominated rate limits per client [ ] Field-level authorization; tenant scoping in loader keys [ ] Error contract in `extensions` (code, retryable, path) [ ] Metrics per operation name and per resolver; resolver-count alarms [ ] Field usage telemetry for @deprecated removals [ ] Timeouts and result-size caps as the last line
Key points
- GraphQL relocates N+1 to resolvers where every flexible query can trigger it; per-request batching is mandatory, not optional.
- Client-composed queries need explicit bounds — depth, complexity,
firstcaps, cost-based rate limits — or the outage is the bound. - Persisted queries close the surface for first-party clients; introspection is gated for everyone else.
- Authorization is per field and parent; caching moves to resolvers and clients; observability moves to operation names.
- The cost machinery is the second half of GraphQL — budget it before the first client ships.
Progressive depth
Overview
GraphQL lets clients ask for any shape; the server must make sure no shape is too expensive, no field is reachable without permission, and every operation is visible in telemetry. That machinery is the cost of the flexibility.
Practical
Add a per-request batching loader to every relationship field, require a capped first on every list, set a depth limit and a complexity budget, gate introspection in production, and report metrics per operation name.
Advanced
Persist first-party queries and reject unregistered ones; denominate rate limits in cost points; put authorization in a directive layer reviewed on every schema change; scope loader keys by tenant so memoization never crosses a boundary.
Internals
Batched loaders turn per-parent lookups into WHERE id IN (…) — a hash or index lookup per key rather than a query per parent — and the execution engine's tick boundary is what lets a loader collect keys before flushing. Complexity estimation multiplies list first arguments down the tree, which is why uncapped lists make any budget meaningless.
GraphQL N+1 Visualizer
Change the contract and observe which guarantee moves.
SELECT * FROM users LIMIT 4 SELECT * FROM orders WHERE user_id = u1 SELECT * FROM orders WHERE user_id = u2 SELECT * FROM orders WHERE user_id = u3 SELECT * FROM orders WHERE user_id = u4 SELECT * FROM items WHERE order_id = o1 SELECT * FROM items WHERE order_id = o2 SELECT * FROM items WHERE order_id = o3 SELECT * FROM items WHERE order_id = o4 SELECT * FROM items WHERE order_id = o5 SELECT * FROM items WHERE order_id = o6 SELECT * FROM items WHERE order_id = o7 SELECT * FROM items WHERE order_id = o8 SELECT * FROM items WHERE order_id = o9 SELECT * FROM items WHERE order_id = o10 SELECT * FROM items WHERE order_id = o11 SELECT * FROM items WHERE order_id = o12
The client’s flexible query moved the join into your resolvers. Batching fixes the round trips — the authorization and query-cost questions remain.
Follow the failure
How the contract fails or gets misused, hop by hop — and what it costs when it completes.
- 1Team → schema: ships resolvers that fetch per parent; the demo with five users is instant.
- 2Client → query: a partner dashboard requests
users { orders { items { product } } }for 2,000 users; the database sees 60,000 queries per page load. - 3Attacker → introspection: reads the schema, finds a recursive
friendsrelationship, and sends a depth-12 query that consumes the connection pool. - 4Ops → dashboard:
/graphqlshows 200s and normal request rate; the outage is visible only as database saturation. - 5Security → incident:
User.emailwas reachable viareview.authorfor any authenticated user; nobody reviewed that path when reviews were added.
- A single unanticipated query saturates the database and takes every client down.
- Data exposure through relationship paths that endpoint-style authorization never covered.
- Read traffic that CDNs absorbed for REST hits resolvers on every request.
- Incidents are invisible on standard HTTP dashboards; detection comes from the database or from customers.
Design, observe, evolve
A contract decision is incomplete until you know how you would notice it failing and how it changes later.
- • Batch every relationship with per-request loaders before any client ships; alarm on resolver counts per request.
- • Require and cap `first` on every list field; enforce depth and complexity budgets; rate-limit by cost.
- • Use persisted queries for first-party clients and gate introspection in production.
- • Authorize per field and parent with a reviewed directive layer; scope loader keys by tenant.
- • Report metrics per operation name and resolver; design the `extensions` error contract explicitly.
- • Resolver invocations per request and database queries per operation — the N+1 signal.
- • Complexity and depth histograms per client; rejections from cost limits.
- • Per-operation-name latency and error codes read from `extensions`, never from HTTP status.
- • Sensitive-field access counts by path, which is the authorization review made continuous.
- • Cost annotations tighten over time from measured resolver costs; start conservative and relax with data.
- • Persisted-query adoption can be phased: log unregistered queries first, then reject.
- • Federation splits ownership as the graph grows, but each subgraph needs its own batching and cost controls.
- • Field usage telemetry turns `@deprecated` into a measured removal process ([[consumer-driven-evolution]]).
- • Loaders, cost annotations and per-field auth are substantial code and tuning that endpoint-style APIs did not need.
- • Depth and complexity limits will reject some legitimate screens; the budget needs a review path, not just a number.
- • Persisted queries constrain the client flexibility that motivated GraphQL — the right trade for first-party apps, unavailable for open partners.
Misconceptions
first caps are the schema's equivalent of pagination on a REST list: the default, not the exception.POST /graphql 200. Monitoring must be rebuilt per operation name and resolver, and errors must be read from the body.