Episode 9 conquers the N+1 problem, GraphQL's classic performance issue: understanding how N+1 arises when resolver chains trigger repeated queries, the DataLoader batching and caching concepts, implementing a batch loader, integrating loaders into the per-request context, and measuring performance improvements.

Episode 6 briefly touched on the danger of resolver chains. Now it's time to dissect that danger in depth: the N+1 problem, GraphQL's most famous performance enemy. Episode 9 explains why GraphQL is vulnerable to it and how DataLoader is the standard solution.
We'll understand the N+1 mechanism, the DataLoader batching and caching concepts, how to implement it, integrating loaders into a per-request context, and techniques for measuring the performance improvement after the loader is installed.
Consider the following query:
query DaftarPost {
posts {
id
title
author {
name
}
}
}If there are 100 posts, the posts resolver triggers 1 query, then the Post.author resolver is called 100 times — once per post — and each call triggers 1 query against the users table. That's 1 + 100 = 101 queries for a single request. That's where the name N+1 comes from.
The cause: GraphQL calls a resolver per-field-per-item, and each resolver independently returns data from parent. This pattern makes queries look efficient on the client, but is wasteful on the database.
The impact is real: request latency balloons, the database exhausts its connections, and infrastructure costs rise. In production with thousands of users, N+1 is the number-one issue discovered during load testing. Early identification can be done by looking at the query log: if you see a pattern of identical queries repeated with different ids, it's almost certainly N+1.
DataLoader is a library designed for this problem with two mechanisms:
load calls within one event loop tick are combined into a single batch function call.npm install dataloaderRun npm install dataloader to add this library to your project.
import DataLoader from "dataloader";
const batchUsers = async (ids) => {
const users = await db.users.findMany({ where: { id: { in: ids } } });
const byId = new Map(users.map((u) => [u.id, u]));
return ids.map((id) => byId.get(id));
};
const userLoader = new DataLoader(batchUsers);Note two important details. First, the batch function receives an array of ids and must return results in the same order as the input. Second, if an id isn't found, return null for its position so the order doesn't shift.
The loader transforms the Post.author resolver from a per-item query into a single batch query:
const resolvers = {
Query: {
posts: async (_, __, ctx) => ctx.db.posts.findMany(),
},
Post: {
author: (post, _, ctx) => ctx.loaders.userById.load(post.authorId),
},
};Now 100 posts only trigger 1 users query. Batching works because all load calls from concurrently running resolvers accumulate within a single tick, then DataLoader combines them.
DataLoader's cache is per-instance. That's why loaders must be created per request, not globally — if global, data that has changed (for example after a mutation) will be hidden behind a stale cache. For data that was just mutated, call loader.clear(key) so it's fetched again, or build a new loader for each request.
If the batch function throws an error, every load in that batch fails. For finer-grained per-item handling, return results per item and throw errors selectively. A common pattern: return null for items not found, and only throw when the whole batch is problematic.
The right place for loaders is inside the context function, which is called per request:
const { url } = await startStandaloneServer(server, {
context: async ({ req }) => ({
user: await authenticateUser(req.headers.authorization),
db,
loaders: {
userById: new DataLoader(batchUsers),
postByAuthorId: new DataLoader(batchPostsByAuthor),
commentByPostId: new DataLoader(batchCommentsByPost),
},
}),
});With this pattern, every request gets a fresh loader instance: batching applies per request, and the cache doesn't leak across requests. All resolvers share the same loaders through ctx.loaders, so there's no duplicated definitions.
Measure the number of database queries before and after using DataLoader. A simple tool: count queries in the log, or use a Prisma plugin for logging. After the loader is installed, the repeated identical query pattern should disappear and be replaced by a single batch query using IN.
To debug batch calls, add a log inside the batch function — you'll see the ids accumulated into a single array, not called one by one. That's proof batching is working. DataLoader can also be given the maxBatchSize option if a single request could produce a giant batch.
Key takeaways:
load calls into one batch function call.loader.clear(key) after mutations so data stays fresh.In the next episode, episode 10, you'll learn about input validation and sanitization — automatic validation at the schema level, integrating Zod for resolver validation, sanitization to prevent XSS and SQL injection, and designing client-friendly error responses. Data entering your server will never be out of control again!