Home › Web Platform › GraphQL N+1 Problem: Fix It With DataLoader
Intermediate 5 min · September 23, 2026

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

N
Naren Founder & Principal Engineer

20+ years shipping production backend systems. Written from production experience, not tutorials.

Follow
✓ Production
production tested
September 27, 2026
last updated
2,085
articles · all by Naren
Before you start⏱ 26 min
  • ✓Working GraphQL server with nested field resolvers
  • ✓SQL query logging enabled in development
  • ✓Basic Node.js and async/await comfort
 ● Production Incident 🔎 Debug Guide
⚡Quick Answer
  • 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
✦ Definition~90s read
What is GraphQL N+1 Query Problem and DataLoader?

In GraphQL, each field has a resolver function. A query for books with their authors runs the books resolver once (query 1: SELECT books), then the author resolver once per book — 100 books means 100 author queries. Total: 1 + 100. That's N+1: one query for the list plus N queries for the relation.

★
Imagine a teacher needing allergy notes for 30 kids.

It composes badly — nested relations multiply: books, then authors, then each author's publisher turns 1+100 into 1+100+100.

Naive resolvers cause it because each resolver only sees its own parent object. The author resolver receives one book and queries one author; it can't know 99 siblings need authors too. ORMs with lazy loading make it worse — touching book.author triggers a hidden query per row with no explicit query call in your code, so the N+1 hides inside innocent property access.

DataLoader fixes both halves. It collects the keys requested during one event-loop tick and fires a single batch function (SELECT ... WHERE id IN (...)), then splits results back per key. Its per-request memoization cache also dedupes repeated loads of the same ID within one query.

Batching collapses N queries to 1; caching removes duplicates. Together they return list queries to constant query counts.

Plain-English First

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.

📊 Production Insight
Linear latency growth with item count plus repeating SELECTs in the log is a closed case. Don't tune indexes or scale the database for an N+1 — batch first.
🎯 Key Takeaway
Same SELECT repeating per row ID in the log is the N+1 fingerprint — confirm with latency at 10/50/200 items.

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.

resolvers-before.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
// BEFORE: each call queries one author — 100 books = 100 queries
const resolvers = {
  Book: {
    author: (book, _args, { db }) => {
      return db.query('SELECT * FROM authors WHERE id = ?', [book.authorId]);
    },
  },
};

// SQL log for 100 books: the same SELECT 100 times, IDs 1..100.
// Add comments + comment authors and it multiplies across levels.
Try it live
📊 Production Insight
Map the relation tree and estimate N per level before writing loaders. The deepest nesting dominates — batch it first.
🎯 Key Takeaway
Resolvers see one parent each, so per-row queries are inevitable without a batching layer.

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.

loaders.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const DataLoader = require('dataloader');

// One batch function per relation: single IN query, key-ordered results
function createLoaders(db) {
  const authorLoader = new DataLoader(async (authorIds) => {
    const rows = await db.query(
      'SELECT * FROM authors WHERE id IN (?)', [authorIds]
    );
    const byId = new Map(rows.map((r) => [r.id, r]));
    return authorIds.map((id) => byId.get(id) ?? null); // keep key order!
  });
  return { authorLoader };
}

// Resolver AFTER: one line, no SQL
const resolvers = {
  Book: {
    author: (book, _args, { authorLoader }) => authorLoader.load(book.authorId),
  },
};
Try it live
📊 Production Insight
Misaligned batch results silently swap rows between parents — the Map-then-map-keys pattern prevents cross-contamination. Test with shuffled DB returns.
🎯 Key Takeaway
Collect keys per tick, run one IN query, return rows in key order — N queries become 1.

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.

server.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
const { ApolloServer } = require('apollo-server');
const { createLoaders } = require('./loaders');

const server = new ApolloServer({
  typeDefs,
  resolvers,
  // Fresh loaders per request: memoization never crosses users
  context: ({ req }) => ({
    ...createLoaders(db),
    user: authenticate(req),
  }),
});

// NEVER this: const authorLoader = new DataLoader(...) at module scope.
Try it live
⚠ Global Loaders Leak Data
A singleton DataLoader caches rows across users — user B can receive user A's private data. Always construct loaders inside the per-request context.
📊 Production Insight
Audit loader construction in every review: inside context factory is safe, module scope is a cross-user leak waiting for an incident.
🎯 Key Takeaway
Build loaders in the request context — singletons leak cached rows across users.

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.

loaders-lists.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
function createCommentsLoader(db) {
  return new DataLoader(async (postIds) => {
    const rows = await db.query(
      'SELECT * FROM comments WHERE post_id IN (?) ORDER BY created_at',
      [postIds]
    );
    const grouped = new Map(postIds.map((id) => [id, []]));
    for (const row of rows) grouped.get(row.post_id).push(row);
    return postIds.map((id) => grouped.get(id)); // [] when none
  });
}

// Resolver stays one line:
// comments: (post, _args, { commentsLoader }) => commentsLoader.load(post.id)
Try it live
📊 Production Insight
Empty-array (not undefined) for childless parents keeps client null-handling clean and avoids DataLoader key errors in production logs.
🎯 Key Takeaway
Group one-to-many batches by key, return [] for empties, and key paginated loads by page too.

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.

📊 Production Insight
A query-count CI budget turns N+1 from a recurring incident into a build failure. Set the ceiling per key query and review bumps skeptically.
🎯 Key Takeaway
Assert flat SQL counts at 10/50/200 items in CI — climbing counts mean a bypassed loader.
● Production incidentPOST-MORTEMseverity: high

Homepage Feed Fired 4,100 Queries Per Page Load

Symptom
After launching a personalized homepage feed, p95 latency climbed to 9 seconds and the database CPU sat at 92%. Each feed load ran about 4,100 SQL queries: 50 posts, each loading author, comments, and each comment's author individually. The database connection pool exhausted twice daily, erroring 300–400 feed loads per incident window.
Assumption
The team blamed the database size and started vertically scaling the RDS instance, doubling cost. When latency barely moved, they blamed the GraphQL gateway overhead and began evaluating a migration back to REST — a three-month project.
Root cause
Three nested per-row resolvers each issued one query per parent: 50 post-author queries, 50 × ~8 comment queries, and ~400 comment-author queries — roughly 4,100 total per feed. The SQL log showed the same three SELECT shapes repeating with different IDs. Scaling the database couldn't help because 4,100 round trips dominate regardless of instance size; the gateway was innocent too.
Fix
They added per-relation DataLoader batching (authors, comments, comment-authors) scoped to the request, collapsing 4,100 queries to 11: one per relation level plus the feed query. p95 fell from 9 seconds to 380ms, database CPU dropped to 24%, and the RDS upgrade was cancelled. A CI assertion now fails any feed query exceeding 20 SQL statements.
Key lesson
  • 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.
Production debug guideFive steps — prove it in logs, batch it, cache it, verify the count.5 entries
Symptom · 01
List queries slow down linearly as items grow
→
Fix
Enable SQL logging (knex onQuery, Sequelize logging, or pg-monitor) and load the list once. If the log shows the same SELECT repeating with different IDs — dozens or hundreds of copies — you've proven N+1. Count the repeats: that number is your N, and collapsing it is the whole job.
Symptom · 02
Resolvers query relations row by row
→
Fix
Create one DataLoader per relation in the request context (e.g. context.authorLoader), with a batch function running a single WHERE id IN (...) query and returning results in key order. Replace the per-row query in the resolver with loader.load(parent.authorId). Reload the list and confirm the log shows one batched query per relation.
Symptom · 03
The same ID loads multiple times within one query
→
Fix
Rely on DataLoader's built-in per-request memoization — repeated 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.
Symptom · 04
Batching works but users see each other's cached data
→
Fix
Move DataLoader instantiation into the per-request context factory so every request gets fresh loaders. A module-level singleton loader caches across requests and leaks one user's rows to another. Audit: grep for new DataLoader outside context creation and move each one inside.
Symptom · 05
Need to prove the fix holds under growth
→
Fix
Add a test asserting SQL statement counts for your key list queries (e.g. feed loads in under 20 statements for 50 items). Run it with 10, 50, and 200 items — counts must stay flat. Flat counts mean batching works; climbing counts mean a relation bypasses its loader.
N+1 Fixes Compared
Root CauseHow to ConfirmFixPrevention
Per-row relation resolversSame SELECT repeats per ID in SQL logDataLoader with IN-query batch functionReview every nested resolver for loader use
Lazy ORM property accessNo query call in code, but log shows N queriesEager-load or route through loadersKeep SQL logging on in development always
Shared IDs fetched repeatedlySame ID queried many times per requestPer-request DataLoader memoizationScope loaders to request context
Unbounded list growthCounts climb with page size despite batchingCap page sizes; chunk giant IN batchesCI statement-count budgets per key query
⚙ Quick Reference
4 commands from this guide
FileCommand / CodePurpose
resolvers-before.jsconst resolvers = {Trace How Per-Row Resolvers Multiply Queries
loaders.jsconst DataLoader = require('dataloader');Batch Relations With DataLoader
server.jsconst { ApolloServer } = require('apollo-server');Scope Loaders Per Request, Never Globally
loaders-lists.jsfunction createCommentsLoader(db) {Handle Ordered and One-to-Many Batches

Key takeaways

1
N+1 is 1 list query plus N per-row queries, multiplying across nesting.
2
Repeating SELECTs in the SQL log are the definitive fingerprint.
3
DataLoader collapses N queries into one key-ordered IN query.
4
Scope loaders per request
globals leak data across users.
5
Group one-to-many batches by key; include page args in keys.
6
Assert flat statement counts in CI at multiple list sizes.

Common mistakes to avoid

5 patterns
×

Returning batch results out of key order

Symptom
Parents receive each other's rows — books show wrong authors with no error, and the corruption looks like a data bug rather than a loader bug.
Fix
Map rows by ID and align to input keys position-for-position, with null for missing. Test with shuffled database return order.
×

Creating one global DataLoader instance

Symptom
Users intermittently see other users' data and server memory climbs steadily — the cross-request cache leaks rows and grows forever.
Fix
Construct loaders inside the per-request context factory. Grep reviews for module-scope DataLoader construction.
×

Scaling the database instead of batching

Symptom
A doubled RDS bill barely moves latency because 4,100 round trips dominate at any instance size — money burned, incident intact.
Fix
Count statements first. If N+1 shows in the log, batch before scaling anything.
×

Keying paginated loads by parent ID alone

Symptom
Page 2 serves page 1's rows because the loader deduped different pages under one key — subtle wrong-data bugs in infinite scrolls.
Fix
Include page arguments in the loader key, or batch paginated fields with grouped queries in the parent resolver.
×

Fixing one level and ignoring deeper nesting

Symptom
Counts drop but stay linear — comment-authors still fire per row after post-authors got batched, so p95 barely improves.
Fix
Map the full relation tree and batch every level. Verify flat counts at 10/50/200 items before calling it done.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What is the GraphQL N+1 problem?
Q02JUNIOR
How does DataLoader fix N+1?
Q03SENIOR
Why must batch results match input key order?
Q04SENIOR
Why must DataLoader instances be request-scoped?
Q05SENIOR
Batching is in place but counts still climb with page size. What next?
Q01 of 05JUNIOR

What is the GraphQL N+1 problem?

ANSWER
One query fetches a list, then per-row resolvers fire one query per item for each relation — 100 books means 100 author queries. Nested relations multiply it across levels. The SQL log shows the same SELECT repeating with different IDs, and latency grows linearly with list size.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
Isn't N+1 a database index problem?
02
Does DataLoader help with REST APIs too?
03
Can I just use JOINs instead of DataLoader?
04
Does the DataLoader cache replace Redis?
05
What batch size limits should I worry about?
06
How do other languages solve N+1?
N
Naren Founder & Principal Engineer

20+ years shipping production backend systems. Written from production experience, not tutorials.

Follow
✓ Verified
production tested
September 27, 2026
last updated
2,085
articles · all by Naren
🔥

That's GraphQL. Mark it forged?

5 min read · try the examples if you haven't

←
Previous
WordPress REST API Returns 401 for Logged-In User
1 / 4 · GraphQL
Next
GraphQL Cannot Return Null for Non-Nullable Field
→