DataLoader (N+1)
DataLoader is the canonical solution to the N+1 query problem in GraphQL. It batches lookup keys collected in a single execution tick into one backend call, and caches per-request so the same id is never fetched twice. One loader per resource (user, product, organisation) wraps the underlying repository.
Per-request DataLoader pattern with batching
EXAMPLE
// npm i dataloader
import DataLoader from 'dataloader';
// 1) Build a loader factory — fresh loaders PER REQUEST (do not share across requests)
function createLoaders(db) {
return {
userById: new DataLoader(async (ids) => {
const rows = await db.users.find({ id: { $in: [...ids] } }).toArray();
const byId = new Map(rows.map((r) => [r.id, r]));
// IMPORTANT: return results in the SAME order as ids (DataLoader contract).
return ids.map((id) => byId.get(id) ?? null);
}),
ordersByUserId: new DataLoader(async (userIds) => {
const rows = await db.orders.find({ user_id: { $in: [...userIds] } }).toArray();
const grouped = new Map();
for (const id of userIds) grouped.set(id, []);
for (const r of rows) grouped.get(r.user_id)?.push(r);
return userIds.map((id) => grouped.get(id) ?? []);
}),
};
}
// 2) Attach loaders to the GraphQL context
async function context({ req }) {
return { db, loaders: createLoaders(db), user: await authFromHeader(req) };
}
// 3) Resolvers use the loaders instead of touching the DB directly
const resolvers = {
Query: {
user: (_, { id }, { loaders }) => loaders.userById.load(id),
},
User: {
// 100 users with .orders each -> ONE batched DB call, not 100
orders: (parent, _, { loaders }) => loaders.ordersByUserId.load(parent.id),
},
Order: {
// Re-uses the per-request cache: the same user requested twice is fetched once
customer: (parent, _, { loaders }) => loaders.userById.load(parent.user_id),
},
};
// 4) Common pitfalls — and the fixes
// a) Cache pollution across requests
// BUG: const userLoader = new DataLoader(...) at module scope
// FIX: build loaders inside context() so they live one request only
// b) Forgetting the order contract
// BUG: return rows from the DB in whatever order Mongo gave you
// FIX: build a Map and map(ids => map.get(id))
// c) Errors per-key are silent
// Return Error instances at the failing position; DataLoader rejects only that key.
// return ids.map((id) => byId.has(id) ? byId.get(id) : new Error('not found ' + id));
// d) Prime the cache when you already have the data
// loaders.userById.prime(user.id, user);
// e) Clear stale entries after a mutation
// loaders.userById.clear(updatedUserId);
// 5) Beyond DataLoader: prisma findMany + select is also batched at the SQL level.
// Always start with one query per resource via DataLoader; reach for
// smarter aggregation only when you measure a real hotspot.
Why it matters
A loader is a per-request object. Module-level loaders cache across users and across mutations, leaking data and serving stale rows — the classic "I see another user`s data" bug in production. Always build loaders inside context() so they live and die with the request.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
import DataLoader from 'dataloader';
const userLoader = new DataLoader(async ids => db.users.findMany({ id: ids }));
Try it Yourself »
Discussion
Loading…