What the N+1 problem looks like in a Node.js GraphQL resolver
Take a schema where Query.posts returns a list of Post, and each Post has an author: User field. The naive resolvers are short. Query.posts runs one SELECT for the posts. Post.author receives the parent post and runs SELECT * FROM users WHERE id = $1 using post.authorId. Each resolver is correct when you test it alone. The problem only appears once the executor combines them.
GraphQL resolves fields one parent object at a time. For 50 posts, the executor calls Post.author 50 separate times, and each call issues its own query. The result is 1 list query plus N author queries, which is where the name N+1 comes from. If many posts share an author, the same user row is fetched repeatedly. Nested selections make it worse: adding comments { author } multiplies the count again.
You can confirm this in your own service with a counter scoped to each request. Create a { queries: 0 } object in your server's context factory and increment it inside your database client wrapper. When the response finishes, log it alongside the operation name. A posts query that logs 51 for 50 results is N+1.
DataLoader is a small Node.js library that addresses this pattern. It collects the individual load(id) calls made during a single tick of the event loop and passes them together to one batch function, which can run a single WHERE id IN (...) query. It also deduplicates repeated keys within a request. It is useful for any team whose resolvers fetch related records by key.
import pg from 'pg';
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
// Context factory: build a fresh counter for every request
export function createContext() {
const stats = { queries: 0 };
const db = {
async query(text, params) {
stats.queries += 1; // count every database round trip
if (process.env.LOG_SQL) console.log(`[sql #${stats.queries}]`, text, params);
return pool.query(text, params);
},
};
return { db, stats };
}
export const resolvers = {
Query: {
// One query for the list of posts
posts: async (_parent, _args, { db }) => {
const { rows } = await db.query(
'SELECT id, title, author_id AS "authorId" FROM posts ORDER BY id LIMIT 50'
);
return rows;
},
},
Post: {
// Runs once per post, so 50 posts cause 50 more queries
author: async (post, _args, { db }) => {
const { rows } = await db.query('SELECT * FROM users WHERE id = $1', [post.authorId]);
return rows[0];
},
},
};
// Call when the response finishes (the hook point depends on your server)
export function logQueryCount(operationName, { stats }) {
console.log(`op=${operationName ?? 'anonymous'} queries=${stats.queries}`); // 51 for 50 posts = N+1
}
Naive posts and author resolvers plus a per-request query counter in context that prints 51 queries for 50 posts, which confirms the N+1 pattern.
How DataLoader batching works
DataLoader does not batch across time windows or timers. It batches across the synchronous part of the current execution. When you call load(key), the loader records the key, creates a Promise for that caller, and schedules a dispatch to run after the current tick's pending promise callbacks have settled. Every other load call made before that dispatch joins the same queue. When the dispatch runs, the batch function receives the full array of keys once, returns results in the same order, and DataLoader resolves each caller's Promise with its own entry.
This fits how GraphQL executes. When a query returns 50 posts, the executor runs the author resolver for all 50 items in one pass. Each call to authorLoader.load(post.authorId) happens before any of them awaits anything, so all 50 keys end up in one batch. When those authors resolve, the executor moves to their child fields, such as author.company, and that level forms its own batch. The result is roughly one query per relation per depth level, rather than one per parent row. A three-level query that would issue hundreds of lookups drops to a few.
The fragile part is awaiting before you call load. Suppose a resolver first awaits a permission check that takes a variable number of ticks and only then calls load. Its key misses the current batch and lands in a later one. If enough resolvers do this, one batch splits into many small ones and the N+1 pattern quietly returns. A reliable rule is to call load as early as possible and do other async work afterward or in parallel.
TICK 1: executor runs the author resolver for all 50 posts
+-----------+ +-----------+ +-----------+
| post 1 | | post 2 | ... | post 50 |
| load(a1) | | load(a2) | | load(a50) |
+-----+-----+ +-----+-----+ +-----+-----+
| | |
v v v
+---------------------------------------------+
| authorLoader queue: [a1, a2, ..., a50] |
| dispatch scheduled after current tick |
+----------------------+----------------------+
| batchFn(keys) runs once
v
+---------------------------------------------+ +------------+
| 1 query: WHERE id IN (a1..a50) |---->| database |
| results returned in key order |<----| |
+----------------------+----------------------+ +------------+
| each caller's Promise gets its own entry
v
LEVEL 2: author.company resolvers -> companyLoader -> one more batch
+---------------------------------------------+
| companyLoader queue: [c1, c2, ..., cN] |---> 1 query
+---------------------------------------------+
PITFALL: awaiting before load()
+-----------------------+ +-------------------------+
| await checkPerm() |---->| load(a7) misses queue |
| (variable ticks) | | -> extra small batch |
+-----------------------+ +-------------------------+
load() calls made in the same tick share one queue. The batch function runs once per relation per depth level, and each caller's Promise resolves with its own entry, while a resolver that awaits before calling load() falls into a separate, smaller batch.
Implementing a loader: the batch function contract and per-request context
The batch function contract
DataLoader calls your batch function with an array of keys and expects a promise resolving to an array of the same length, in the same order. For users, issue a single SELECT ... WHERE id IN (...) with the collected keys. Then build a Map from each row's id to the row, and return keys.map(key => map.get(key) ?? null). A missing key gets null, or an Error instance if the caller should treat absence as a failure. DataLoader rejects only that key's load call, not the whole batch.
Do not return the rows exactly as the database sent them. SQL makes no ordering guarantee for IN queries, and missing rows shorten the array. Both cases silently shift results, so post A gets user B's author and nothing throws. Also normalize key types before you look them up. GraphQL IDs arrive as strings, but your driver may return numeric IDs, and Map treats "42" and 42 as different keys.
Create loaders per request
Instantiate loaders inside the GraphQL context factory so each request gets fresh instances. A loader's cache memoizes every result for the instance's lifetime. As a module-level singleton, one user's authorized data gets served to another user's request, results go stale after writes, and memory grows without bound.
Bound and verify the queries
Set maxBatchSize so a large list doesn't produce an IN clause that exceeds your database's parameter limits or degrades the query plan. Then enable query logging in your driver or ORM, run a representative query, and confirm you see one IN query per entity type rather than one query per row.
import DataLoader from 'dataloader';
import { Pool } from 'pg';
const pool = new Pool(); // connection settings come from PG* env vars
type User = { id: string; name: string; email: string };
async function batchUsers(keys: readonly string[]): Promise<(User | Error)[]> {
// One query per batch: WHERE id IN ($1, $2, ...)
const placeholders = keys.map((_, i) => `$${i + 1}`).join(', ');
const { rows } = await pool.query(
`SELECT id, name, email FROM users WHERE id IN (${placeholders})`,
keys as string[]
);
// Index rows by a normalized string id. An integer column comes back as a
// number, and Map treats 42 and "42" as different keys.
const byId = new Map<string, User>();
for (const row of rows) byId.set(String(row.id), { ...row, id: String(row.id) });
// Return results in KEY order, same length as keys. Never return `rows` as-is:
// IN has no ordering guarantee and missing rows would shift every result.
// Use `?? null` instead if absence is not an error for callers.
return keys.map((key) => byId.get(key) ?? new Error(`User ${key} not found`));
}
export function createLoaders() {
return {
// maxBatchSize caps the number of IN parameters per query
user: new DataLoader<string, User>(batchUsers, { maxBatchSize: 500 }),
};
}
// Context factory: runs once per request, so each request gets a fresh cache.
// A module-level loader would share cached rows across users and grow forever.
export const context = async () => ({ loaders: createLoaders() });
export const resolvers = {
Post: {
author: (post: { authorId: number }, _args: unknown, ctx: Awaited<ReturnType<typeof context>>) =>
ctx.loaders.user.load(String(post.authorId)), // normalize the key before loading
},
};
A user batch function that runs one parameterized IN query and maps rows back to key order through a Map with normalized string IDs, plus loaders created per request in the context factory with maxBatchSize set.
One-to-many relations, composite keys, caching, and errors
For a one-to-many relation such as comments by postId, the batch function still returns exactly one entry per key, and each entry is an array. Fetch all comments with a single WHERE post_id IN (...) query, group the rows into a map keyed by postId, then build the result by mapping over the input keys in order. Return an empty array for posts with no comments rather than undefined. DataLoader resolves values by position, so a missing slot or a misaligned order hands one post's comments to another.
Composite keys
When a key is an object, such as { postId, locale }, the default cache compares by reference, so two equal objects created in separate resolvers are treated as different keys and never deduplicate. Pass a cacheKeyFn that serializes the key to a stable primitive, for example ${postId}:${locale}.
Caching
The cache is a memoization layer scoped to one loader instance, which is why loaders are created per request. After a mutation changes a record, call clear(key) so later loads in the same request read fresh data. If you already fetched an entity through another path, use prime(key, value) to seed it and skip a redundant query. Set cache: false when a request must see repeated, changing reads, or when the cached values would be large and short-lived.
Errors and authorization
If the batch promise rejects, every pending load in that batch rejects. If you place an Error instance at a specific position instead, only that field fails, and GraphQL returns partial data alongside an errors entry. Do not let the cache bypass authorization. Enforce permissions inside the batch function or in the resolver after the load, because a cached value can otherwise be returned to a caller who is not allowed to see it.
import DataLoader from 'dataloader';
type Comment = { id: string; postId: string; body: string };
type TitleKey = { postId: string; locale: string };
declare const db: { query<T>(sql: string, params: unknown[]): Promise<T[]> };
// Create once per request so the cache never leaks across users.
export function createLoaders(viewerId: string) {
const commentsByPostId = new DataLoader<string, Comment[]>(async (postIds) => {
// Single query for all posts (Postgres ANY = IN list). Visibility is enforced here, before caching.
const rows = await db.query<Comment>(
'SELECT id, post_id AS "postId", body FROM comments WHERE post_id = ANY($1) AND (is_public OR author_id = $2) ORDER BY created_at',
[postIds, viewerId]
);
const grouped = new Map<string, Comment[]>();
for (const row of rows) {
grouped.set(row.postId, [...(grouped.get(row.postId) ?? []), row]);
}
// One entry per key, in key order; [] instead of undefined for posts without comments.
return postIds.map((id) => grouped.get(id) ?? []);
});
const titleByPostLocale = new DataLoader<TitleKey, string>(async (keys) => {
const rows = await db.query<{ postId: string; locale: string; title: string }>(
'SELECT post_id AS "postId", locale, title FROM post_titles WHERE (post_id, locale) IN (SELECT * FROM unnest($1::text[], $2::text[]))',
[keys.map((k) => k.postId), keys.map((k) => k.locale)]
);
const byKey = new Map(rows.map((r) => [`${r.postId}:${r.locale}`, r.title]));
// An Error at one position fails only that field; the rest of the response still resolves.
return keys.map((k) => byKey.get(`${k.postId}:${k.locale}`) ?? new Error(`No title for ${k.postId}/${k.locale}`));
}, { cacheKeyFn: (k) => `${k.postId}:${k.locale}` }); // equal objects dedupe by value, not reference
return { commentsByPostId, titleByPostLocale };
}
// After a mutation in the same request: drop the stale entry, or seed it with known-fresh data.
export function afterAddComment(loaders: ReturnType<typeof createLoaders>, postId: string, all: Comment[]) {
loaders.commentsByPostId.clear(postId).prime(postId, all);
}
A per-request comments-by-postId loader that returns grouped arrays in key order with authorization in the batch, a composite-key loader using cacheKeyFn and per-position Error values, and clear/prime after a mutation.
Verifying the fix and keeping N+1 from returning
Start by making the problem measurable. Wrap your database client so every query increments a counter attached to the request context, then log that counter when the response is sent. Most Node.js drivers and ORMs expose a query event or logging hook, and a thin wrapper around your own query function works just as well. Run the same nested query before and after adding the loader. Take, for example, a list of 50 posts, each with an author. Before the change, the count grows with the list (1 + 50). After it, the count should stay flat (1 + 1). The exact numbers matter less than the shape: the count should depend on query depth, not on result size.
Lock it in with a test
Treat the count as an assertion. Write an integration test that seeds a fixed dataset, executes a representative nested query through the real schema with a fresh per-request context, and asserts the exact query count. Seed enough rows that a regression is obvious. With three parents, 1 + 3 is easy to overlook. With twenty, 1 + 20 is not. Assert an exact number rather than an upper bound, so an unexpected extra query fails the build and gets an explanation.
Watch the ORM
ORMs with lazy-loaded relations are the most common way N+1 returns. If a resolver accesses something like post.author on an ORM entity, the ORM may issue its own query and skip your loader entirely. Resolvers should fetch related records only through loaders, and that rule should be enforced in code review. The query-count test will catch the cases review misses.
When to use DataLoader, when to avoid it, and the alternatives
DataLoader fits best where a list field resolves related entities by key: each post's author, each order's customer, each line item's product. The resolver stays local and readable, and the batch function turns scattered lookups into one query per entity type per tick.
Batching does not remove the round trips caused by depth. Each nesting level waits for its parent level to resolve, so a query five levels deep still makes at least five sequential database calls. Wide schemas add a batch per entity type. If a hot path always fetches the same shape, such as an order page with its line items and products, a single join or a purpose-built query resolver usually beats a chain of loaders on both latency and query plan.
DataLoader's cache lasts only for one request. If the same records are requested across many requests, you need a shared application cache in front of the loader. That cache brings invalidation work, and it brings authorization risk: entries must be scoped by tenant and permission, or one user's result can be served to another.
| Approach | Round trips | Caching scope | Complexity | Main risk |
|---|---|---|---|---|
| DataLoader | One per level per entity type | Single request | Low | Depth still serializes |
| SQL join | One | None | Medium | Row explosion on one-to-many |
| Purpose-built resolver | One or few | None | Medium | Couples resolver to query shape |
| ORM eager loading | One or few | None | Low | Over-fetching unrequested fields |
| Shared cache | Zero on hit | Cross-request | High | Stale or unauthorized data |
Impact on development cost and engineering time
DataLoader is not free. Its costs are small and predictable, and they are paid once per relation. The cost of N+1 grows with every list field a client requests.
| Cost | Why it happens | Mitigation |
|---|---|---|
| Loader boilerplate per relation | Each key shape needs its own batch function | Generate loaders from a shared factory |
| Strict batch contract | Results must match key order and length | Centralize the reorder-by-key step |
| Harder tracing | Queries run on a later tick, away from the resolver that requested them | Tag queries with the loader name |
| Scheduling latency | Loads wait for the current tick to collect keys | Usually negligible next to a database round trip |
| Stale cache after mutations | The per-request cache still holds pre-write values | Call clear or prime on the key after writing |
The savings come from keeping resolvers simple. Each resolver asks for one record by key, and they compose without knowing what their siblings fetch. You avoid hand-written joins tuned to one query shape, and those joins stop being correct when clients change their selections. A query-count assertion in tests turns a performance regression into a failing build, so you find it before production does.
Illustrative estimate (assumed numbers, not a benchmark)
Assume a page of P = 20 posts. Each post has an author and C = 5 comments, and each comment has an author.
- Naive resolvers: 1 + P + P + P·C = 1 + 20 + 20 + 100 = 141 queries
- With DataLoader: one query per batch level: posts, post authors, comments, and comment authors = 4 queries
The naive count grows linearly with P and C. The batched count depends only on how many levels deep the query nests.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to graphql dataloader n+1, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.