Stylesgraphqln+1dataloaderquery costpersisted queriescaching

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.

▶ Run the labFollow the failure

Frame the contract

API design starts with a consumer, a design question and a guarantee — never with a URL.

Design question
Who bounds the cost of a query you never anticipated — the schema, the resolvers, or the outage — and what machinery moves that answer from "outage" to "schema"?
Consumers
The platform team operating a GraphQL API against real clients, and the client teams whose freely composed queries have to remain fast and safe — including partners and browsers whose queries you cannot review before they run.
The promise
Every query's cost is bounded and predictable before execution, resolvers batch instead of multiplying, authorization holds on every field regardless of path, and operations are observable by name — so client flexibility never becomes server fragility.
RequirementConsumersResource ModelStyleContractValidationAuthorizationErrorsIdempotencyPaginationVersioningObservabilityEvolutionTrade-offs

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).

Naive resolvers: one query per parent, per level
1const resolvers = {
2 Query: { users: () => db.users.findMany({ take: 100 }) },
3 User: { orders: (user) => db.orders.findMany({ where: { userId: user.id } }) }, // ×100
4 Order: { items: (order) => db.items.findMany({ where: { orderId: order.id } }) }, // ×1000
5}
6// users { orders { items } } → 1 + 100 + 1000 round trips
Batched loaders: one query per level
1const 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-request

The 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.

The cost-control layers and what each catches
ControlBoundsMissesCost to run
Per-request batching (loaders)N+1 multiplicationWide queries with large firstLoader per relationship; per-request context
Depth limitRunaway nestingWide, shallow queriesOne number; false positives on legitimately deep screens
Complexity budgetWide × deep queries by pointsMis-estimated field costsCost annotations per field; tuning
Cost-based rate limitSustained expensive traffic per clientOne-shot spikes under the limitPoint accounting per client
Persisted queriesEverything for first-party clientsThird parties you cannot registerBuild-time registration pipeline
Timeouts + result size capsAnything that slipped throughWasted work already doneCancellation 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.

The operating budget GraphQL requires, as a checklist
[ ] 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, first caps, 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.

GraphQL N+1 Visualizer
One query — users { orders { items } } — and what the resolvers do to the database.
Database round trips
17
1 (users) + 4 (orders per user) + 4×3 (items per order)
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.

  1. 1
    Team → schema: ships resolvers that fetch per parent; the demo with five users is instant.
  2. 2
    Client → query: a partner dashboard requests users { orders { items { product } } } for 2,000 users; the database sees 60,000 queries per page load.
  3. 3
    Attacker → introspection: reads the schema, finds a recursive friends relationship, and sends a depth-12 query that consumes the connection pool.
  4. 4
    Ops → dashboard: /graphql shows 200s and normal request rate; the outage is visible only as database saturation.
  5. 5
    Security → incident: User.email was reachable via review.author for any authenticated user; nobody reviewed that path when reviews were added.
What breaks
  • 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.

Design the contract
  • • 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.
Observe in production
  • • 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.
Evolve without breaking
  • • 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]]).
What it costs
  • • 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

Claim
“GraphQL solves N+1.”
Reality
It relocates N+1 from the client to your resolvers, where every flexible query can trigger it. Batching with per-request loaders is your job, and it is not optional.
Claim
“Query cost is a rare edge case.”
Reality
Any client can compose any query, and introspection tells them how. Depth, complexity and first caps are the schema's equivalent of pagination on a REST list: the default, not the exception.
Claim
“One endpoint is easier to monitor.”
Reality
One endpoint hides every operation behind POST /graphql 200. Monitoring must be rebuilt per operation name and resolver, and errors must be read from the body.

Apply it