DeepFrontend
Practice ArenaBlogSign in
Go Pro
DeepFrontend

Interview-grade learning paths and hands-on practice for mid-to-senior engineers. Master internals, patterns, and system architecture with runnable code.

All systems operational

Core Courses

  • -JavaScript Internals
  • -React Reconciliation
  • -TypeScript rigor
  • -Next.js Caching
  • -Node.js Event Loop
  • -System Design
  • -Web Security
  • -CSS & Page Layouts

Explore

  • Learning Paths
  • Practice Arena
  • Pricing Options
  • HTML Sitemap

Resources

  • Privacy Policy
  • Terms of Service
  • Email Support

© 2026 DeepFrontend. All rights reserved.

Expert learning environments for web engineering teams.

Home/GraphQL/N+1 Problem & DataLoader
advanced25 min read·Updated Jun 2025

N+1 Problem & DataLoader

DataLoader's batching window is tied directly to the JavaScript event loop's tick boundaries, not a fixed time window — confirmed by issuing two .load() calls synchronously (batched into one call) and a third via setTimeout (a separate batch entirely). Calls collected within the same synchronous execution batch together; calls separated by a macrotask boundary don't.

n-plus-onedataloaderbatchingperformance

Knowledge Check

10 questions · pass at 70%

0/10
easymcq

1. For a query returning a list of N users, each with a nested posts field resolved via a separate database call per user, how many total queries fire?


Interview Questions

2 questions

Cheatsheet

Download-ready reference
Cheatsheet
N+1 PROBLEM: list query (1) + one nested-field query PER ITEM (N)
  = N+1 total queries. Scales LINEARLY with result size.
  Invisible with small dev datasets — a REAL production incident
  at scale (1000 users → 1001 queries).

DATALOADER FIX:
  const loader = new DataLoader(async (keys) => batchFetch(keys));
  resolver: (parent) => loader.load(parent.id)  // minimal code change

  CONFIRMED behavior:
  ✅ BATCHES — all .load() calls in the same window → ONE batch call
  ✅ DEDUPLICATES — duplicate keys in-flight → appear ONCE in the batch
  ✅ CACHES per-key WITHIN the loader instance (until .clear(key))

  ⚠️ BATCHING WINDOW = MICROTASK timing, NOT a fixed duration:
     loader.load(1); loader.load(2);      → SAME batch (sync, same tick)
     setTimeout(() => loader.load(3));    → SEPARATE batch (new macrotask)
     (see Event Loop (JavaScript) — same microtask/macrotask distinction)

  ⚠️⚠️ CRITICAL: create a NEW DataLoader instance PER REQUEST
     (e.g. in the server's per-request `context` function).
     Sharing ONE instance across requests → cache leaks data
     ACROSS users/requests — a real, confirmed correctness bug,
     not just a style preference.

DIAGNOSTIC SIGNATURE of N+1: query count scales WITH result size.
  (not just "slow" — SCALING slow, specifically.)

Related topics

Schema DesignProEvent LoopPro

On this page

25m
Knowledge CheckInterview QuestionsCheatsheet

Concept#

The beginner framing: GraphQL's resolver-per-field execution model — genuinely useful for flexibility — has a sharp edge that catches nearly everyone the first time they build a real GraphQL API backed by a database: the N+1 problem, the single most commonly asked GraphQL interview question.

The problem, precisely#

query {
  users {
    id
    posts { title }
  }
}
N+1: one query for the list, then ONE MORE query PER item in that list
step 1 / 3
query {
users {
id
posts { title }
}
}
users resolver
active
ONE query: SELECT * FROM users
→
posts resolver ×N
idle
not yet

The users resolver runs once, fetching all users in a single query — say, 3 users come back.

The users resolver runs once, fetching all users in a single query. But GraphQL invokes the posts resolver once per user — with each invocation having no awareness the others are happening — so each one fires its own separate database query. 3 users means 1 (for the list) + 3 (one per user) = 4 total queries. This is bad enough to notice at 3 users; at 1,000 users, it's 1,001 queries — a genuine production incident, not a theoretical concern.

The fix: DataLoader — batching AND automatic deduplication#

const postsLoader = new DataLoader(async (userIds) => {
  return batchFetchPostsByAuthorIds(userIds); // ONE query for ALL requested ids
});
 
// resolver code barely changes:
const resolvers = {
  User: {
    posts: (user) => postsLoader.load(user.id),
  },
};
DataLoader: the SAME resolver code, but calls are collected and batched into ONE query
step 1 / 4
const postsLoader = new DataLoader(async (userIds) => {
// ONE query for ALL requested ids, not one per id
return batchFetchPostsByAuthorIds(userIds);
});
postsLoader
idle
created once, shared across the request

A DataLoader wraps a batch-fetching function — instead of fetching one id at a time, its batch function receives an ARRAY of ids and returns results for all of them in one call.

Confirmed by running this exact scenario: .load() doesn't fetch immediately — it queues the requested key and returns a pending promise. All .load() calls issued within the same batching window are collected, then the batch function fires once, with an array of all the collected keys. Confirmed precisely: 3 .load() calls, including one duplicate key, produced exactly one batch call, and the batch function received the keys with the duplicate automatically removed — DataLoader deduplicates identical in-flight requests, not just batches distinct ones.

The batching window is tied to the event loop, not a timer#

loader.load(1); // synchronous
loader.load(2); // synchronous — SAME tick as the line above
 
setTimeout(() => {
  loader.load(3); // a LATER tick — a separate macrotask
}, 10);

Confirmed by running this exact code: .load(1) and .load(2), called synchronously back-to-back, batched into one call with keys [1, 2]. .load(3), issued inside a setTimeout callback, triggered a separate, second batch call entirely — confirmed via the batch counter reaching exactly 2. DataLoader's batching window isn't a fixed duration — it flushes once the current synchronous execution completes (technically, on the next microtask tick) — so calls collected within one execution context of resolvers running for a single GraphQL operation batch together naturally, while calls separated by a macrotask boundary (like setTimeout, or genuinely separate requests) don't.

The critical operational gotcha: one DataLoader instance per request#

// ❌ WRONG — a single shared loader across ALL requests
const postsLoader = new DataLoader(batchFn);
app.use("/graphql", graphqlServer({ context: () => ({ postsLoader }) }));
 
// ✅ CORRECT — a FRESH loader created for EACH incoming request
app.use("/graphql", graphqlServer({
  context: () => ({ postsLoader: new DataLoader(batchFn) }),
}));

Confirmed by testing DataLoader's cache behavior directly: repeated .load() calls for the same key, even outside the immediate batching window, return the cached result without re-fetching — the cache only clears via an explicit .clear(key) call. This is genuinely useful within a single request (avoiding redundant fetches for the same entity referenced multiple times in one query), but it's a serious bug if the same loader instance is reused across requests: one user's request could receive another user's cached data, or genuinely stale data that should have been re-fetched. The standard, correct pattern is instantiating a new DataLoader per request (typically inside the server's per-request context function), never sharing one globally.


Try It#

Predict the outcome before checking the solution.

const userLoader = new DataLoader(batchFetchUsers);
 
async function resolvePost(post) {
  const author = await userLoader.load(post.authorId);
  return { ...post, author };
}
 
// resolving 5 posts, some sharing the same author, all synchronously kicked off:
const posts = await Promise.all(rawPosts.map(resolvePost));

If 5 posts are being resolved and 2 of them share the same authorId, how many total keys does the batch function actually receive?

Solution

4 unique keys, not 5. All 5 .load() calls are issued synchronously (via the .map() inside Promise.all), so they fall within the same batching window and collect into one batch call — but since 2 of the 5 posts share the same authorId, DataLoader's automatic deduplication means that repeated key only appears once in what's actually sent to the batch function. The two posts sharing an author both receive the correctly-resolved value once the batch completes, from the single underlying fetch.


Implement It Yourself#

Build a minimal batching loader from scratch, to see the actual mechanism DataLoader wraps:

function createBatchLoader(batchFn) {
  let queue = []; // pending { key, resolve, reject }
  let scheduled = false;
 
  function scheduleFlush() {
    if (scheduled) return;
    scheduled = true;
    queueMicrotask(() => { // flush on the NEXT microtask — same mechanism DataLoader uses
      const currentQueue = queue;




























queueMicrotask is the actual mechanism — scheduling the flush for the next microtask means every .load() call made synchronously before the current execution yields gets collected into the same batch, exactly matching the confirmed real DataLoader behavior above.


Under the Hood#

DataLoader's batching window being tied to microtask timing rather than a fixed duration is a direct, practical application of the event loop's priority model covered in Event Loop (JavaScript) — specifically, the distinction between microtasks (which drain before the loop proceeds) and macrotasks like setTimeout (which wait for a later phase). Understanding that model generally is exactly what makes DataLoader's "calls in the same tick batch, calls across a setTimeout don't" behavior predictable rather than mysterious.


Common Mistakes#

1. Sharing one DataLoader instance across multiple requests#

const sharedLoader = new DataLoader(batchFn); // ❌ created ONCE, reused for every request

Confirmed via testing — DataLoader's per-key cache persists until explicitly cleared, so a shared instance risks serving stale or cross-request-leaked data. Always instantiate fresh, per request.

2. Expecting DataLoader to batch calls separated by a macrotask boundary#

loader.load(1);
setTimeout(() => loader.load(2), 0); // ❌ this will NOT batch with the call above

Confirmed via testing — a setTimeout, even with a 0ms delay, is a separate macrotask; DataLoader's microtask-timed flush has already happened by the time that callback runs.

3. Assuming the N+1 problem only matters for very large datasets#

query { users { posts { title } } } # "it's fine, we only have 10 users in dev"

The problem is invisible in local development with small datasets and becomes a genuine production incident at scale — the query count scales linearly with result size, so it's worth fixing proactively for any nested resolver pattern, not only after it's already caused a real slowdown.


Best Practices#

  • Use DataLoader (or an equivalent per-request batching mechanism) for any resolver that fetches related data based on a parent field's value — this is close to a default requirement for production GraphQL APIs backed by a database, not an optional optimization.
  • Always create a fresh DataLoader instance per request, typically inside the server's per-request context setup — never share one globally across requests.
  • Don't rely on DataLoader batching across a macrotask boundary — if a load genuinely needs to happen after a setTimeout/similar delay, it won't batch with synchronously-issued loads, by design.
  • Profile actual query counts, not just response time, when investigating suspected N+1 issues — a slow response can have many causes, but a query count that scales with result size is the specific, diagnostic signature of N+1.

Performance Tips#

  • N+1 query count scales linearly with result size (1 + N) — DataLoader-based batching collapses this to a small, roughly constant number of queries regardless of N, which is a qualitatively different scaling behavior, not just a modest improvement.
  • DataLoader's per-key cache (confirmed to persist within a request until cleared) also eliminates redundant fetches when the same entity is referenced multiple times within one query — a secondary, genuine performance benefit beyond just batching.

queue = [];
scheduled = false;
const uniqueKeys = [...new Set(currentQueue.map((item) => item.key))]; // DEDUPE
batchFn(uniqueKeys).then((results) => {
const resultByKey = new Map(uniqueKeys.map((key, i) => [key, results[i]]));
currentQueue.forEach(({ key, resolve }) => resolve(resultByKey.get(key)));
});
});
}
return {
load(key) {
return new Promise((resolve, reject) => {
queue.push({ key, resolve, reject });
scheduleFlush();
});
},
};
}
const loader = createBatchLoader(async (keys) => {
console.log("Batch fetching:", keys);
return keys.map((k) => `value-${k}`);
});
loader.load(1);
loader.load(2);
loader.load(1); // duplicate — deduped in the batch, but still resolves correctly