N+1 isn't a database problem. It's a shape.
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. Each round-trip is 5ms, 105ms total. A single JOIN does it in 7ms.
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 accessTo 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])
)
);"I parallelized it, much faster." It is faster. 20 × 5ms one-by-one was 100ms. Send all 20 at the same time and the latency drops to the slowest one, around 5ms. Looks solved.
But the DB still receives 20 queries. You only changed them from running one by one to running all at once. If the connection pool has 10 connections, those 20 queries now compete for connections and wait anyway. You hid the latency problem and bought a throughput problem.
Promise.all does parallelism, not combining. The real problem with N+1 is too many requests. Parallelism doesn't cut the count, it only makes them happen at the same time. To actually remove N+1, turn N requests into 1. Two different things. Don't mix them up.
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. Fastest on Postgres. The cost: 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 >── rolesSo 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 queriesThe 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 clean fix puts both hops into that batch function. The resolver layer doesn't change at all, still calling .load() one at a time:
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. 3 keys may share the same role, so after removing duplicates you query 3 roles, not 5. The point: all of this stays inside the batch function. The resolver neither sees it nor needs to. N+1 is handled underneath, while the top stays a clean "load one at a time."
Who calls whom
Four players, 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 SQLThe 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, one query covers the whole 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 indexThat's the whole idea: collect during the round, send at the boundary. DataLoader gets the timing right using the event loop's microtask and process.nextTick order, so the dispatch waits until every .load() of the round has been made, but no longer. The exact scheduling trick is worth reading once, but you don't need it to use DataLoader correctly. What you do need is the next part.
The way to break batching
Once you understand how "one round" is defined, you know what breaks it. Put a real await (yielding to real I/O) in the middle and you cut the round in two:
// ✓ 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. The only thing that breaks batching is a sequential loop you wrote by hand.
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| DataLoader | p-limit | |
|---|---|---|
| Solves | N+1 (too many queries) | pool exhaustion (too many connections at once) |
| How | collects .load(), merges into one batch query | caps how many async run at the same time |
| Connections | 5 .load() use just 1 connection | 100 tasks capped to 10 connections at once |
| Fits | rows that merge into WHERE IN (...) | tasks that each do something different (read → decide → update) |
They're never used together. With DataLoader, after batching you use only 1 connection, so there's no need for p-limit's throttling. With p-limit, each task does something different and can't merge into one query, so DataLoader can't help. 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 turns one-by-one into parallel, but the request count doesn't drop. It hides the slowness and keeps the same number of requests. 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 a two-layer Promise.then + process.nextTick wrap to place the dispatch in exactly the right gap of the event loop, quietly combining it into a single IN.