GraphQL N+1 Problem: Fix It With DataLoader
Batch per-row resolver queries with DataLoader — the GraphQL N+1 problem turns one list query into hundreds of database hits..
20+ years shipping production backend systems. Written from production experience, not tutorials.
- ✓Working GraphQL server with nested field resolvers
- ✓SQL query logging enabled in development
- ✓Basic Node.js and async/await comfort
- N+1 means one query fetches the list, then N more fetch each row's relation one by one
- Spot it in SQL logs: the same SELECT repeating with different IDs is the signature
- DataLoader batches those N calls into one IN query and caches per request
- Scope loaders per request — a shared global loader leaks user data across users
- Assert flat SQL counts in CI so new nested fields can't reintroduce the pattern
Imagine a teacher needing allergy notes for 30 kids. The N+1 way is calling the office 30 times, once per kid. DataLoader is handing over the class list once and getting all 30 notes back together. Same information, one trip instead of 31. Your database feels the difference as the class grows to thousands — and so does your p95 latency chart. Batching is the habit; the library is just packaging.
Your GraphQL list query works fine with 10 items — then takes 4 seconds with 200. The SQL log shows the same SELECT statement hundreds of times, differing only in the ID. You haven't written a slow query; you've written hundreds of fast ones, and the round trips add up.
This is the N+1 problem, the most common GraphQL performance bug. The parent resolver fetches the list (1 query), then the child resolver fires once per row (N queries) to load each relation. REST endpoints dodge this with hand-tuned joins per route; GraphQL's per-field resolvers walk straight into it unless you batch.
This guide shows you the pattern, the proof, and the fix. You'll recognize N+1 in SQL logs, batch relations with DataLoader, add per-request caching, and verify the query count collapses. The SQL log is the recurring hero of this guide — every claim below gets proven there, not in benchmarks. The examples use Node.js, but the batching principle applies to every GraphQL server. Keep a query log open while reading; each section maps to something you can check.
Recognize the N+1 Signature in Your Logs
N+1 has a fingerprint: one list query followed by N identical-shape queries differing only in ID. SELECT * FROM authors WHERE id = 1, then = 2, then = 3, hundreds of times. Total time isn't one slow query — it's hundreds of fast queries plus network round trips, which is why adding database indexes never helps.
Growth behavior confirms it. Time a list query at 10, 50, and 200 items: linear slowdown (200ms, 1s, 4s) with query counts climbing proportionally means N+1. Constant query counts with rising time would point elsewhere — payload size or a missing index. Measure both latency and statement count at each size.
ORMs hide the queries. Lazy-loaded relations (book.author triggering a fetch on access) put no query call in your resolver code, so the resolver looks innocent while the log screams. Count statements per request, not just total time — APM tools that show call counts per transaction expose N+1 even when sampling hides individual fast queries. Enable SQL logging in development always — the log is the only honest witness when abstractions hide database access. Save one offending log excerpt per incident; the gallery trains new hires faster than any lecture on resolver design.
Trace How Per-Row Resolvers Multiply Queries
Walk the execution: the books resolver runs one SELECT returning 100 rows. GraphQL then calls the author resolver 100 times, once per book, and each call queries its author. Authors repeat across books, so many of those 100 queries fetch the same rows repeatedly. Add a comments level and each book fires another query — then each comment fires an author query. Three levels, thousands of statements.
Each resolver's isolation is the root cause. The author resolver sees one parent book; it cannot coordinate with the 99 sibling calls happening around it. Without a batching layer, per-row querying is the only thing a resolver can do — the abstraction guarantees N+1 by construction.
Map your own query tree before fixing: list each nested relation and estimate N at each level. Multiply list sizes down the tree — 50 posts times 8 comments times authors predicts the ~450-query floor before caching. The deepest level usually dominates (comment-authors outnumber post-authors), so batch the deepest relation first for the biggest immediate win. This map also becomes your verification checklist — every level needs a loader. Redraw the map after each schema change; new relations arrive without loaders by default.
Batch Relations With DataLoader
DataLoader sits between resolvers and the database. Resolvers call loader.load(id) instead of querying; the loader collects keys for one event-loop tick, then calls your batch function once with all keys. Your batch function runs a single WHERE id IN (...) query and returns rows in the same order as the keys. DataLoader splits them back to each waiting resolver.
Key order is the contract that bites. The batch function must return results aligned position-for-position with the input keys, inserting null for missing rows — otherwise resolvers receive each other's data. Build a Map from row id to row, then map keys through it. Test with deliberately unordered database returns to prove alignment.
One loader per relation, all created per request in context: authorLoader, commentsByPostLoader, and so on. Prime loaders where you already hold rows — loader.prime(id, row) after the list query skips re-fetching authors you fetched. The resolver becomes a one-liner delegating to its loader. Reload the list and watch the log: N author queries collapse into one IN query. Repeat per level until statement counts go flat with item growth. Commit the before/after counts in the pull request; reviewers trust numbers over claims.
Scope Loaders Per Request, Never Globally
DataLoader memoizes: the second load() of an ID within its lifetime skips the database. That cache must live exactly one request. Create loaders inside the context factory so each request gets fresh instances sharing nothing. The memoization then dedupes repeated IDs within one query — five books by one author cost one row fetch — and dies with the request.
A module-level singleton loader is a data-leak bug. Its cache persists across requests, so user A's author row serves user B's query — including rows B must not see. It also grows unboundedly, leaking memory on every new ID until the process restarts. Both failures are silent: no error, just wrong data and climbing RSS.
Enforce the rule structurally: context functions create loaders, modules never do. Grep for new DataLoader outside context factories during review. In serverless deployments the per-invocation context usually satisfies this naturally — verify rather than assume, since reused warm containers can carry module state further than expected. Add a context-factory unit test asserting fresh loader instances per call; the test fails loudly if someone hoists construction. Pair with request-scoped database transactions where needed so batched reads see a consistent snapshot within the request.
Handle Ordered and One-to-Many Batches
Many relations aren't single-row lookups. Comments-by-post returns lists per key: the batch function queries WHERE post_id IN (...) once, groups rows by post id, and maps each key to its array (empty array, never undefined, for posts with none). Order within each group follows your ORDER BY — set it explicitly since IN queries don't preserve input order.
Paginated relations need composite keys. A loader keyed by post id alone can't distinguish page 1 from page 2 — include the page arguments in the key (an object or tuple) and batch accordingly, or bypass DataLoader for paginated fields and batch with a grouped query in the parent resolver. Mixing pages under one key serves page 1's rows to page 2's requester.
Nulls and empties must be explicit. Single-row loaders return null for missing IDs; list loaders return []. A missing key that returns undefined makes DataLoader throw a batch error for the whole tick — one gap poisons sibling loads. Resolvers and client code branch on these, so a dropped key surfaces as a DataLoader error rather than clean null propagation. Cover both cases in loader unit tests with seeded gaps. Fuzz batch functions with shuffled and duplicated keys; order bugs hide behind sorted fixtures.
Verify Counts Stay Flat as Lists Grow
Proof is statement counts at multiple sizes. Log SQL for the same list query at 10, 50, and 200 items: batched relations show flat counts (the feed incident went 4,100 to 11 and stayed there), while any bypassed relation climbs linearly. A climbing count after the fix means one resolver still queries directly — grep resolvers for raw db.query calls on related tables.
Lock it with a CI assertion. Wrap test queries in a statement counter and fail above a budget (20 statements for the feed). This catches regressions the day someone adds a shiny new nested field with a naive resolver, instead of the quarter you discover p95 tripled.
Watch the batch sizes too. An IN clause with 10,000 IDs has its own limits — cap list page sizes, and split giant key sets into chunks inside the batch function if needed. Most databases handle a few hundred IDs per batch comfortably; thousands deserve chunking plus a page-size review. Flat counts plus bounded batches is the steady state: fast lists at any page size, proven by tests rather than hoped for. Graph the counts on the team dashboard; flat lines build confidence that survives reorgs. Alert on budget breaches in CI notifications so regressions page the author, not the on-call.
Homepage Feed Fired 4,100 Queries Per Page Load
- Count queries before scaling hardware. 4,100 round trips won't fit any instance size — batching beat a doubled RDS bill in one afternoon.
- Nested relations multiply N+1 across levels. Audit the deepest relation first, since comment-author style nesting dominates the total count.
- Gate list queries with query-count assertions in CI. A 20-statement ceiling would have caught this before launch instead of after.
load() calls for one ID hit the cache, not the database. Verify by loading a list where several rows share an author: the SQL log should show each author ID once. Never add a second cache layer inside the batch function itself.| File | Command / Code | Purpose |
|---|---|---|
| resolvers-before.js | const resolvers = { | Trace How Per-Row Resolvers Multiply Queries |
| loaders.js | const DataLoader = require('dataloader'); | Batch Relations With DataLoader |
| server.js | const { ApolloServer } = require('apollo-server'); | Scope Loaders Per Request, Never Globally |
| loaders-lists.js | function createCommentsLoader(db) { | Handle Ordered and One-to-Many Batches |
Key takeaways
Common mistakes to avoid
5 patternsReturning batch results out of key order
Creating one global DataLoader instance
Scaling the database instead of batching
Keying paginated loads by parent ID alone
Fixing one level and ignoring deeper nesting
Interview Questions on This Topic
What is the GraphQL N+1 problem?
Frequently Asked Questions
20+ years shipping production backend systems. Written from production experience, not tutorials.
That's GraphQL. Mark it forged?
5 min read · try the examples if you haven't