One innocent-looking query — a list of posts, each with its author — walked into production and fired 501 SQL statements against PostgreSQL. One for the list, five hundred for the authors. This is the classic N+1 explosion, and DataLoader is the standard fix.
The query looked trivial. Fetch the latest 500 posts, and for each post show the author’s name. In SQL you would write that as a join and move on. In GraphQL, the resolver graph does something quietly catastrophic: it runs one query to load the posts, then runs the author resolver once per post, each time hitting the database on its own. The list query is the “1”; the per-row author lookups are the “N”. At 500 rows that is 501 round trips, most of them fetching authors you have already loaded on the row above.
Why the resolver graph does this to you
GraphQL resolvers run per field, per object. When a query returns a list of 500 posts and asks for author { name } on each, the executor calls the Post.author resolver 500 times — once for every post object, with no knowledge that its 499 siblings are asking the same kind of question at the same moment. Each call does its own SELECT ... FROM authors WHERE id = $1. The database is not the problem here; the fan-out is. Nothing in the resolver contract batches these calls for you, which is precisely the gap DataLoader was built to close.
This is why the symptom scales with data, not with load. Your integration tests pass because the fixture has three posts. The staging demo is fine because nobody scrolled. Then a real list renders, the row count climbs, and latency and database connections climb linearly with it. It is one of the more common reasons a service that looked healthy in review falls over the first time it meets production-sized data.
Spotting it before it spots you
The tell is a wall of near-identical statements in the query log, differing only by the bound parameter:
-- PostgreSQL log with log_min_duration_statement = 0
LOG: duration: 0.4 ms execute: SELECT id, title, author_id FROM posts ORDER BY created_at DESC LIMIT 500
LOG: duration: 0.2 ms execute: SELECT id, name, email FROM authors WHERE id = $1 -- $1 = 12
LOG: duration: 0.2 ms execute: SELECT id, name, email FROM authors WHERE id = $1 -- $1 = 47
LOG: duration: 0.2 ms execute: SELECT id, name, email FROM authors WHERE id = $1 -- $1 = 12
-- ... 497 more of these ...
Note the repeated $1 = 12: the same author fetched twice because two posts share her. In an APM trace the same shape shows up as a single GraphQL operation span with hundreds of tiny, sequential database child spans stacked underneath it — a solid bar of red where a healthy resolver should show one or two queries. This is exactly the kind of regression that decent observability surfaces early; if you already instrument latency and change-failure rate properly, an N+1 that slipped through review shows up as a step change in P95 latency the moment real data hits it.
What DataLoader actually does
DataLoader sits between your resolvers and your database as a batching and caching layer scoped to a single request. Instead of calling the database directly, each author resolver calls loader.load(authorId), which returns a promise. DataLoader collects every key requested within one tick of the event loop, then calls your batch function once with the full array of keys. Five hundred .load() calls collapse into a single SELECT ... WHERE id = ANY($1). Because it also memoises within the request, the two posts that share an author generate one key, not two.
Two properties make this safe rather than clever. First, the batch runs on the next tick, so it captures every load queued during synchronous resolver execution without you having to coordinate anything. Second, the cache is per-request and nothing else — it exists only to avoid loading the same key twice inside one operation, not to replace Redis or any shared cache. That scoping is the whole game, and getting it wrong is the one way DataLoader bites back.
Wiring it into Apollo Server 4
The batch function is where the correctness lives. It receives an array of keys and must return an array of values of exactly the same length, in exactly the same order. Your query will not return rows in that order, and it will silently drop keys that have no matching row — so you re-index against the keys yourself:
// loaders.js — Node 22, DataLoader 2.x, node-postgres (pg)
import DataLoader from 'dataloader';
import { pool } from './db.js';
// Batch function: given many author ids, return one array of authors
// in the SAME order as the ids, with null for any id that has no row.
async function batchAuthors(ids) {
const { rows } = await pool.query(
'SELECT id, name, email FROM authors WHERE id = ANY($1::int[])',
[ids],
);
const byId = new Map(rows.map((row) => [row.id, row]));
return ids.map((id) => byId.get(id) ?? null); // realign to keys, fill misses
}
// Fresh loaders per request — never share across requests or users.
export function createLoaders() {
return {
authorById: new DataLoader(batchAuthors),
};
}
In Apollo Server 4 the context function runs once per HTTP request, which is exactly the boundary you want for loader lifetime. Build the loaders there and hand them to resolvers through context:
// server.js
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import { createLoaders } from './loaders.js';
import { typeDefs, resolvers } from './schema.js';
const server = new ApolloServer({ typeDefs, resolvers });
const { url } = await startStandaloneServer(server, {
listen: { port: 4000 },
// Runs per request: fresh loaders, no cross-request data leak.
context: async () => ({ loaders: createLoaders() }),
});
console.log(`Ready at ${url}`);
The resolver then loses its direct database call and defers to the loader:
// resolvers.js
export const resolvers = {
Post: {
// Before: db.query('SELECT ... FROM authors WHERE id = $1', [post.authorId])
// After: one batched, cached load per request.
author: (post, _args, { loaders }) => loaders.authorById.load(post.authorId),
},
};
Nothing in the schema or the resolver signature changed. The 501 statements become two: one for the posts, one for the batched authors.
The two rules you cannot break
Return the array aligned to the keys. If your batch function returns authors in database order, or omits the misses, DataLoader will hand post 12’s author to post 47 and quietly corrupt the response. There is no error — just wrong data. The Map-then-ids.map() pattern above is not decoration; it is the contract. Every batch function needs an equivalent realignment step.
Instantiate loaders per request, never once at module load. A loader created at startup and shared across requests will serve one user cached rows that were loaded under another user’s permissions, and will hold stale data for the lifetime of the process. This is the most dangerous failure mode, because it passes every test and only surfaces as a data-leak incident in production. Build them in context, and let each request get its own. If you want a guard against the N+1 creeping back, a query-count assertion in an integration test — wired into the same CI checks that already gate your merges — will fail the build the moment a resolver starts fanning out again.
DataLoader is thirty lines of wiring, but it only works because of two constraints that the type system will not enforce for you: same-order output, and per-request lifetime. Get those right and 501 queries become two. Get either wrong and you have traded a performance bug for a correctness one — which is a far worse trade to discover in production.
Build and rescue work
Hands-on delivery of this kind is handled by Sixteen Pillars Studio.
Free interactive tool
Website compliance checklist
What your site has to do, based on what it actually does
Answer as much or as little as you like — the list builds as you go. Nothing is stored against your name and no email is required.
Everything that applies
Ordered by what to do first: legal requirements you can close quickly, then larger pieces of work, then what is expected rather than required. Not exhaustive, and not a legal audit.
Dated PDF, yours to keep or circulate.
Can you trust the architecture you have?
Architecture diagrams rarely show the reality of how systems actually operate. An independent review establishes what is really there.