Diagnosing and Fixing the GraphQL N+1 Query Problem with Per-Request Batching
A GraphQL endpoint that slows linearly with list size almost always has the N+1 resolver problem. Here's how to confirm it with query counts and fix it with per-request DataLoader batching.
16 Aug 2026, 23:50 UTC

A GraphQL endpoint that feels fine with three items in a list but falls over with three hundred almost always has the same cause: the N+1 problem. One query for the list itself, then one extra query per item for a nested field. The fix is well established — batch those per-item lookups into a single backend call using the DataLoader pattern — but you should confirm the diagnosis before touching code, because the symptoms overlap with plain slow queries and missing indexes.
The recognizable condition
You run a query like this:
query {
posts(first: 100) {
title
author { name }
}
}
Your database log shows one SELECT ... FROM posts LIMIT 100, followed by one hundred nearly identical statements that differ only by the bound ID: SELECT ... FROM users WHERE id = $1, repeated. Query count grows linearly with list size, and p95 latency climbs with it. CPU on the app server looks fine; the database is doing the work.
Cause and diagnostic summary
| What you observe | Likely cause | Confirm by |
|---|---|---|
| Query count grows 1:1 with list size | Per-item resolver calls the data layer directly | Count queries per operation in logs/APM at two list sizes |
| Repeated SELECTs differing only by ID | No batching layer; each parent object resolves independently | Inspect the suspect resolver for a direct ORM/DB call |
| Flat query count but still slow | Not N+1 — missing index or expensive join | Run EXPLAIN on the single slow statement |
| Stale or wrong-tenant data after adding batching | DataLoader instance shared across requests | Check where the loader is constructed; it must be per-request |
Why resolvers do this
GraphQL executes resolvers independently for each field on each object in the result. If the list has 100 posts and each post resolves author by calling db.users.findById(post.authorId), that call happens 100 times. The execution engine has no way to know those calls could be merged — that is your job, and the supported pattern for it is a per-request batching and caching layer, most commonly the DataLoader library (available in JavaScript and ported to most GraphQL server ecosystems).
Ordered checks before you change anything
- Count backend queries per GraphQL operation. Turn on query logging in your ORM or database, or add an APM trace span around data access. Run the suspect operation twice: once with a list of 10, once with 100. If the query count goes from ~11 to ~101, you have confirmed linear growth — N+1. If it stays flat, stop here and profile the individual query instead.
- Find the resolver responsible. The repeated statement's table tells you which field. Open that resolver and look for a direct call to the data layer with no batching wrapper.
- Check whether the backend can batch at all. A SQL database almost always can (
WHERE id IN (...)). A legacy REST API with only single-record endpoints cannot — batching won't help there; see the escalation section. - Check for an existing loader that's scoped wrong. If batching exists but you see cross-request staleness or tenant leakage, the loader is probably constructed at server startup instead of per request.
The fix: a per-request DataLoader
DataLoader collects keys requested within a single execution tick, deduplicates them, calls your batch function once, and caches results for the lifetime of the instance. The critical rule: create a fresh instance per request, typically in the context factory. Sharing one across requests serves stale data and can leak data between tenants.
JavaScript example (assumes the dataloader package, any modern GraphQL server):
const DataLoader = require('dataloader');
// Runs where your per-request context is built.
// Requires: nothing global; `db` is your data access module.
function createContext() {
const userLoader = new DataLoader(async (userIds) => {
// One query for all IDs collected this tick.
const users = await db.users.findMany({ id: { in: userIds } });
const byId = new Map(users.map((u) => [u.id, u]));
// Must return results in the same order as userIds.
return userIds.map((id) => byId.get(id) ?? null);
});
return { userLoader };
}
const resolvers = {
Post: {
author: (post, _args, ctx) => ctx.userLoader.load(post.authorId),
},
};
Two details matter. First, the batch function must return results in key order, with null (or an Error) for missing keys — DataLoader matches by position, not by ID. Second, deduplication and caching are per instance, which is why instance lifetime must equal request lifetime.
Verifying the fix
Re-run the same check that confirmed the problem: execute the list query at size 10 and size 100 with query logging on. The backend query count should now be a small constant (typically 2: one for the list, one batched lookup) regardless of list size. For a durable guard, add a per-request query counter around your data layer and assert in a test that it stays flat as list size grows. Finally, load-test with realistic list sizes and compare p95 latency and total query volume against your pre-fix baseline — batching shifts work from many round trips to one larger query, which is usually but not always a win at extreme batch sizes.
Secondary hardening: bound the blast radius
Batching fixes amplification per item, but a malicious or careless client can still request enormous lists or deeply nested selections. Two standard mitigations complement DataLoader:
- Query depth and complexity limits, enforced at validation time by most server frameworks. Assign field costs (a list field costs more than a scalar) and reject operations over a budget. Keep limits generous enough for legitimate clients — overly strict depth caps break real queries, so measure your actual traffic first.
- Persisted queries for first-party clients: the server only executes pre-registered operations, eliminating arbitrary expensive queries entirely.
When to escalate beyond batching
If query counts are flat but latency is still poor, N+1 was never your real problem. Escalate in this order:
- Schema and data design. A field that always needs a join may belong denormalized onto the parent. Long lists should use cursor-based pagination (
first/after) so no single operation materializes unbounded rows. - HTTP/CDN caching for public data. If responses are not user-specific, cache at the edge with appropriate cache-control headers rather than re-executing on the origin.
- Backend capability. If the underlying API genuinely cannot batch, consider a server-side cache with short TTL, or question whether that endpoint belongs behind GraphQL at all.
Limitations to keep in mind
DataLoader batches within a tick, so keys requested across separated awaits may still split into multiple batches — usually harmless, occasionally worth restructuring. The per-request cache means a mutation followed by a read in the same operation can return the pre-mutation value; call loader.clear(key) or prime it after writes. And batching reduces round trips, not total rows read — if the batched IN query itself is slow, you're back to indexing and query tuning, not more GraphQL plumbing.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.