N+1 isn't a database problem. It's a shape.

2026/06/28
n-plus-1dataloadergraphqlevent-loopnodejsperformance

A page needs the author name for 20 posts. The data model is the conventional one: each post stores an author_id, and the name lives in the users table. One post has one author, one author has many posts (many-to-one).

posts(id, title, author_id)   author_id → users.id
users(id, name)

The obvious way to fetch the names is a for loop:

const posts = await db.query('SELECT * FROM posts LIMIT 20');  // 1 query
 
for (const post of posts) {
  post.author = await db.query(
    'SELECT * FROM users WHERE id = ?', [post.author_id]
  );  // ← one query per post, runs 20 times
}

1 + 20 = 21 queries. If each round-trip is 5ms, that's 105ms total. A single JOIN would be one round-trip.

It reads like an ordinary loop. But every iteration sends a network request. That's N+1.

Textbooks put it under "database optimization." I think that placement hides the truth: N+1 has nothing essential to do with databases. It's a shape.

A shape, not SQL

Strip the code down and the skeleton is this:

take a list (1 query)
for each element, make one round-trip (N queries)

What the round-trip is doesn't change whether the shape holds. It can be:

  • a SQL query (the most common version)
  • an HTTP call to another microservice
  • a Redis GET
  • a gRPC call
  • a file read

The moment you send one cross-boundary request per item across N items, you have N+1. The only difference is the cost of that boundary. SQL is a network round-trip, HTTP is more expensive, a file read is cheaper. The shape is identical. So instead of memorizing "don't query inside a loop," remember: don't cross a boundary inside a loop.

Three forms of the same shape

Same shape, different forms across frameworks. The hard part is the last two, where the loop is invisible.

The plain for loop. The opening snippet. At least it's honest: the loop is visible in the code.

ORM lazy load. The loop is still there, but the query disappeared:

# Django
posts = Post.objects.all()       # 1 query
for post in posts:
    print(post.author.name)      # ← a hidden query on attribute access

To make post.author feel like a plain object, the ORM sends a query the instant you read that attribute. The person writing it feels nothing. Only a profiler or the SQL log shows that one print line is 20 round-trips.

GraphQL resolvers. Now even the loop is gone:

{
  posts {
    title
    author { name }
  }
}

You wrote no loop. The GraphQL engine wrote one for you: it takes 20 posts and runs the author resolver once per post, each one querying the user table. Same 1+N. The framework hid the loop, and you only see a clean declarative query.

Three forms, one shape. The further down you go, the more invisible the loop, the harder to notice in code review.

Promise.all looks like a fix, but it isn't

The first instinct: just send them all with Promise.all.

const posts = await db.query('SELECT * FROM posts LIMIT 20');
await Promise.all(
  posts.map(post =>
    db.query('SELECT * FROM users WHERE id = ?', [post.author_id])
  )
);

Written this way, the page gets faster. One by one, the 20 author queries take 20 × 5ms = 100ms (the 105ms at the start also counts the 1 query for the post list). With 20 or more connections in the pool, all 20 go out at once and the latency drops to the slowest one, around 5ms. With only 10 connections, they go in two waves, around 10ms.

But the DB still receives 20 queries, not one fewer. Connection time is about the same too: one connection held for 100ms becomes 20 connections held for 5ms each. A JOIN gets all the data with 1 connection and 1 round-trip.

To actually remove N+1, turn N requests into 1.

Three real fixes

All doing the same thing: combine N requests into 1. They differ in which layer they combine at.

JOIN (combine at the DB layer):

SELECT p.*, u.name AS author_name
FROM posts p JOIN users u ON p.author_id = u.id
LIMIT 20;

One statement covers it. The catch: it is tied to a single DB and a single query, and can't reach across services.

IN batch (combine manually at the application layer):

const posts = await db.query('SELECT * FROM posts LIMIT 20');
const authorIds = posts.map(p => p.author_id);
const users = await db.query('SELECT * FROM users WHERE id IN (?)', [authorIds]);
const userMap = new Map(users.map(u => [u.id, u]));
posts.forEach(p => p.author = userMap.get(p.author_id));

21 down to 2. One more round-trip than JOIN, but the logic is clear, and the data can live across different services / caches / DBs. The key step is that middle line posts.map(p => p.author_id): you have one place to collect every id at once.

DataLoader (combine automatically at the application layer): for when you don't have that one place to collect ids. GraphQL resolvers are exactly that case. Each resolver sees only its own post and has no idea the other 19 exist. The rest of this article is about this.

The clean fix: when two tables need joining

The earlier example crosses one table (post → user). Real-world N+1 is often two hops. Here's a production case.

A GraphQL query pulls a list of API keys, each carrying its adminRoles:

query {
  apiKeys {
    name
    adminRoles { id, name }   # ← triggers a lookup per key
  }
}

adminRoles can't come from one table. The data model is a many-to-many: one API key holds many roles, one role attaches to many keys. A junction table links holds the pairing, and the role bodies live in roles.

api_keys(api_key, name)
links(api_key, admin_role_id)      ← junction, many-to-many
roles(id, name, description)
 
api_keys ──< links >── roles

So fetching adminRoles for one key is two hops: api_keys → links (find its role ids) → roles (load those roles). The naive field resolver:

@ResolveField(() => [ApiKeyAdminRoleDto], { name: 'adminRoles' })
async adminRoles(@Parent() apiKey: ApiKeyDto) {
  const links = await this.queryService.findAdminRolesByApiKey(apiKey.apiKey);  // hop 1
  const roleIds = links.map(l => l.adminRoleId);
  const roles = await this.roleRepository.listByIds(roleIds);                    // hop 2
  return roles.map(/* ... */);
}

5 API keys, count the queries:

1 × SELECT api_keys
5 × SELECT links  WHERE api_key = ?    ← once per key
5 × SELECT roles  WHERE id IN (...)    ← once per key
= 11 queries

The DataLoader here isn't a concept I wrote by hand. It's Facebook's open-source npm package dataloader, the standard choice for GraphQL backends. new DataLoader(batchFn) wraps a batch function, and it handles collect + merge + resolve-in-order. You only write the IN query.

The fix is a new loader whose batch function holds both hops. The resolver still handles one key at a time; it just calls .load() now:

import DataLoader from 'dataloader';
 
@Injectable({ scope: Scope.REQUEST })   // one independent instance per request
export class ApiKeyAdminRoleLoaderService {
  // type: load(string) → returns ApiKeyAdminRole[]
  private readonly loader: DataLoader<string, ApiKeyAdminRole[]> =
    new DataLoader(this.#batchLoad.bind(this));
 
  // DataLoader gathers every .load() key from the same round, calls this once
  async #batchLoad(apiKeyIds: string[]): Promise<ApiKeyAdminRole[][]> {
    // hop 1: one query for all links
    const allLinks = await this.linkRepository.findLinksByApiKeys(apiKeyIds);
 
    // hop 2: one query for all roles
    const roleIds = [...new Set(allLinks.map(l => l.adminRoleId))];
    const roles = await this.roleRepository.listByIds(roleIds);
    const roleMap = new Map(roles.map(r => [r.id, r]));
 
    // group by apiKey; return order must match input order
    const grouped = new Map<string, ApiKeyAdminRole[]>();
    for (const link of allLinks) {
      const role = roleMap.get(link.adminRoleId);
      if (role) {
        if (!grouped.has(link.apiKey)) grouped.set(link.apiKey, []);
        grouped.get(link.apiKey)!.push(role);
      }
    }
    return apiKeyIds.map(id => grouped.get(id) || []);
  }
 
  load(apiKeyId: string) { return this.loader.load(apiKeyId); }
}

The resolver becomes one line:

@ResolveField(() => [ApiKeyAdminRoleDto], { name: 'adminRoles' })
async adminRoles(@Parent() apiKey: ApiKeyDto) {
  const roles = await this.loader.load(apiKey.apiKey);
  return roles.map(/* ... */);
}

Result:

1 × SELECT api_keys
1 × SELECT links  WHERE api_key IN (5 keys)
1 × SELECT roles  WHERE id      IN (3 roles)
= 3 queries   (11 → 3, -73%)

Each hop combines into one IN, and the second hop removes duplicate ids with new Set along the way. Several of the 5 keys share a role, so after removing duplicates only 3 roles need querying. Combining both hops lives entirely in the batch function; the resolver is down to one .load() line.

Who calls whom

Top to bottom:

your resolver  →  .load(id)          (once per parent)
DataLoader     →  collects the ids, calls your batch function once
your batchFn   →  one query: findMany({ id: { in: ids } })
ORM            →  SELECT ... WHERE id IN (...)
database       →  runs the SQL

The common misread: DataLoader does not turn .load() into a query. It only collects keys and gets the timing right. You write the query, inside the batch function. The ORM and the database do their normal jobs, unaware DataLoader exists. Open each player for the detail.

DataLoader — collects and times, never touches the DB

.load(id) pushes the id into an array and returns a pending promise. No query yet. DataLoader waits out the round (the event-loop timing from the next section), then calls your batch function once with the full array of ids, and when the results come back, resolves each promise by index. It knows nothing about SQL, ORMs, or tables. It holds keys and promises, nothing more.

Your batch function — the one place the query is decided

DataLoader hands you [id1, id2, …]. What query to run is your decision. You write the single IN lookup here:

new DataLoader(async (ids) => {
  const rows = await prisma.user.findMany({ where: { id: { in: [...ids] } } });
  const map = new Map(rows.map(r => [r.id, r]));
  return ids.map(id => map.get(id) ?? null);   // same order as ids
});

The findMany({ in: ids }) is yours, not something DataLoader generated. You also map the rows back in the same order as the ids, or each caller gets the wrong row.

The ORM — turns findMany into SQL

prisma.user.findMany({ where: { id: { in: ids } } }) becomes SELECT * FROM users WHERE id IN ($1, $2, …). Normal ORM translation, the same as any other query. Swap Prisma for TypeORM (In(ids)) or Drizzle (inArray(users.id, ids)) and the SQL is the same. None of this knows DataLoader is involved.

Under the hood: how DataLoader "auto-collects"

Resolvers call .load() one by one. How does DataLoader know when to send them together? The JavaScript event loop.

The plain version first. DataLoader puts a short pause between the moment you call .load() and the moment the DB is actually queried. In that pause it collects every key from the round. Without the pause, the call and the query are stuck together, so you query the DB once for every call. With it, each loader sends one query per round. Everything below is just how it places that pause.

Now the mechanics. .load(key) doesn't touch the DB. It pushes the key into a batch and returns a pending promise right away. Every .load() made in the same round of the event loop lands in that one batch. At the end of the round, DataLoader dispatches once: it calls your batch function (the IN query), and after the DB returns, it fills each promise in order.

load(key1) load(key2) … load(key5)   ← same round, all pushed into one batch
        ↓ end of the round
batchFn([key1..key5]) → 1 × SELECT ... IN (...)
        ↓ DB returns
resolve each .load() promise by index

So when does a "round" end? Take await Promise.all(ids.map(id => loader.load(id))). In order:

1. map runs load(id1): the key joins the batch, DataLoader schedules a dispatch (sending the batch)
2. map keeps going, load(id2) … load(id100): every key joins the same batch
3. map finishes, the synchronous code ends
4. promise callbacks already queued run first; any .load() inside them also joins this batch
5. the dispatch runs: the batch function is called once with every key

Step 4 is common in GraphQL. A child resolver only starts after its parent resolver's promise resolves, so the child's .load() calls happen inside exactly this kind of callback.

The dispatch scheduled in step 1 is Promise.resolve().then(() => process.nextTick(dispatch)). It is wrapped twice because each layer covers one step:

  • .then: its callback can't run until step 3 is over, so every key from step 2 makes it in.
  • process.nextTick: it is only called inside that .then callback, and Node runs the dispatch it queues after the currently queued promise callbacks finish, so the keys from step 4 make it in too.

Call process.nextTick(dispatch) directly in step 1 instead, and the dispatch runs before those step 4 promise callbacks. The next GraphQL level's keys would miss this batch.

The way to break batching

Put a real await (yielding to real I/O) between two .load() calls and the batch is sent early at that point. If a loop awaits on every iteration, 100 loads become 100 batches:

// ✓ same round: 100 loads in one batch → 1 query
await Promise.all(ids.map(id => loader.load(id)));
 
// ✗ sequential loop: each await yields, dispatch runs early, each its own batch → 100 queries
for (const id of ids) await loader.load(id);

The second one is the common mistake. Each await loader.load(id) waits for this load to finish before the next iteration, so each iteration becomes its own batch. You think DataLoader keeps you safe, and you end up back at N+1.

But GraphQL resolvers, where each field does its own await load(), are safe. Don't confuse the two. The engine calls sibling resolvers in parallel, so those .load() calls happen in the same round and the same batch. What to watch for is a sequential loop you wrote by hand, because it puts an await between two .load() calls.

Don't confuse it with p-limit: different problems

One last common mix-up. DataLoader and p-limit both "control a pile of async," but they solve opposite problems.

DataLoader:  many asking the same question → merge into one, ask once
p-limit:     many requests at the same time → queue up, let 10 through at a time
DataLoaderp-limit
SolvesN+1 (too many queries)pool exhaustion (too many connections at once)
Howcollects .load(), merges into one batch querycaps how many async run at the same time
Query count5 .load() on the same loader become 1 query100 tasks still run 100 times, at most 10 connections at once
Fitsrows that merge into WHERE IN (...)tasks that each do something different (read → decide → update)

They're usually not used on the same code. DataLoader merges a batch of keys into one query, so the query count is already low and p-limit's throttling has nothing to do. In the API key example, however many keys come in, it queries links once and roles once. p-limit is for tasks that can't merge, because each one does something different, and there DataLoader can't help.

DataLoader can still run the connection pool short, though. If one level has three fields, each with its own loader, those three loaders dispatch in the same round and one request needs three connections at once; ten concurrent requests need thirty. Then you can enlarge the pool, or use p-limit to cap how many requests are handled at once. Once connections run out, Prisma lets a query wait 10 seconds by default (pool_timeout) and then fails it; the pg driver's connectionTimeoutMillis defaults to 0, so it waits indefinitely (Prisma docs).

Back to that Promise.all problem from the start: if your case genuinely can't batch and you must send N separate requests, that's when p-limit is used. Not to remove requests, but to keep them from overloading the connection pool all at once.


N+1 isn't a SQL thing. It's the shape "take a list, cross a boundary once per element." It goes invisible inside ORM lazy load and GraphQL resolvers, and the more invisible, the harder to notice. Promise.all makes the page faster, but the request count doesn't drop. The real fix combines N into 1: JOIN, manual IN, or DataLoader. The good thing about DataLoader is that the top stays a simple "load one at a time," while underneath it uses two layers, Promise.then plus process.nextTick, to place the dispatch in exactly the right gap of the event loop, so the keys one loader receives in one round become a single IN.