The N+1 Problem in GraphQL
When you resolve a list of users and each user has a profile, you're hitting the database once for users, then once per user for their profile—that's N+1 queries. DataLoader for GraphQL N+1 solves this by batching relation lookups into a single database call per request, eliminating the cascade of queries that tank performance. I've seen this pattern save queries from 50+ down to 2 or 3 in production systems handling thousands of concurrent requests.
The core idea is simple: collect all the IDs you need during a single execution phase, query them all at once, and cache the results for the lifetime of that request. In NestJS with GraphQL, this means creating loaders at request scope and injecting them into your resolvers.
One Loader Per Relation, Per Request
The fundamental principle is that each relation gets its own loader instance, and that loader exists only for one HTTP request. This isolation is crucial—you don't want to leak cached data from user A's request into user B's request.
Here's how I structure it in practice. First, I create a loader factory:
import DataLoader from 'dataloader';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './entities/user.entity';
import { Profile } from './entities/profile.entity';
@Injectable()
export class DataLoaderService {
constructor(
@InjectRepository(User) private userRepo: Repository<User>,
@InjectRepository(Profile) private profileRepo: Repository<Profile>,
) {}
createUserLoader() {
return new DataLoader<number, User>(async (userIds) => {
const users = await this.userRepo.find({
where: { id: In(userIds) },
});
const userMap = new Map(users.map((u) => [u.id, u]));
return userIds.map((id) => userMap.get(id));
});
}
createProfileLoader() {
return new DataLoader<number, Profile>(async (profileIds) => {
const profiles = await this.profileRepo.find({
where: { id: In(profileIds) },
});
const profileMap = new Map(profiles.map((p) => [p.id, p]));
return profileIds.map((id) => profileMap.get(id));
});
}
}
Notice the key pattern: the batch function receives an array of IDs, fetches them all in one query using IN, maps the results back to a Map for O(1) lookup, and returns them in the same order as the input. DataLoader handles the queuing and batching automatically—if 10 resolvers request the same ID within the same event loop tick, only one database query fires.
Batches IDs Into a Single IN Query
The magic happens in the batch function. Instead of executing 100 individual queries for 100 user IDs, DataLoader collects them and fires one SELECT * FROM users WHERE id IN (1, 2, 3, ..., 100). This is a massive performance win.
Let me show you a real resolver without DataLoader first:
@Resolver(() => Post)
export class PostResolver {
constructor(private userService: UserService) {}
@ResolveField(() => User)
async author(post: Post) {
// This fires once per post in the list!
return this.userService.findById(post.authorId);
}
}
If you query 50 posts, that's 50 user queries. With DataLoader:
@Resolver(() => Post)
export class PostResolver {
constructor(private loaderService: DataLoaderService) {}
@ResolveField(() => User)
async author(post: Post, @Context() { userLoader }) {
// All 50 user IDs are batched into one query
return userLoader.load(post.authorId);
}
}
The userLoader.load(id) call returns a Promise that resolves once the batch executes. DataLoader automatically deduplicates too—if two posts have the same author ID, you get the cached result for free.
Caches Within the Request Only
DataLoader's cache is scoped to a single instance. This means if you create a loader at the start of a request and use it throughout that request's entire resolution chain, every call to load(id) with the same ID returns the cached value immediately without hitting the database again.
Critically, the cache is request-scoped—it doesn't persist between HTTP requests. This prevents data leaks and ensures fresh data on every request. In NestJS, you achieve this using the REQUEST scope:
import { Scope } from '@nestjs/common';
@Injectable({ scope: Scope.REQUEST })
export class DataLoaderContext {
private loaders = new Map<string, DataLoader<any, any>>();
constructor(private dataLoaderService: DataLoaderService) {}
getUserLoader() {
if (!this.loaders.has('users')) {
this.loaders.set('users', this.dataLoaderService.createUserLoader());
}
return this.loaders.get('users');
}
getProfileLoader() {
if (!this.loaders.has('profiles')) {
this.loaders.set('profiles', this.dataLoaderService.createProfileLoader());
}
return this.loaders.get('profiles');
}
}
Every HTTP request gets a fresh instance of DataLoaderContext, which means fresh loaders, which means fresh caches. After the request completes, the instance is garbage collected.
Request-Scoped or Context-Created Loaders
You have two patterns: inject the context provider into every resolver (verbose) or attach loaders to the GraphQL context object (cleaner). I prefer the context approach.
Set up the GraphQL module to build context with loaders:
import { Module } from '@nestjs/common';
import { GraphQLModule } from '@nestjs/graphql';
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
@Module({
imports: [
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
autoSchemaFile: true,
context: ({ req }) => {
return {
userLoader: new DataLoader(async (userIds) => {
// batch logic here
}),
profileLoader: new DataLoader(async (profileIds) => {
// batch logic here
}),
};
},
}),
],
})
export class GraphQLConfigModule {}
Then access loaders in resolvers via the @Context() decorator:
@Resolver(() => Post)
export class PostResolver {
@ResolveField(() => User)
async author(post: Post, @Context() { userLoader }) {
return userLoader.load(post.authorId);
}
}
This is lean and composable. For larger apps, I move loader creation into a dedicated service to keep the GraphQL config clean:
@Injectable({ scope: Scope.REQUEST })
export class GraphQLContextService {
constructor(
private dataLoaderService: DataLoaderService,
@Inject(REQUEST) private req: Request,
) {}
build() {
return {
userLoader: this.dataLoaderService.createUserLoader(),
profileLoader: this.dataLoaderService.createProfileLoader(),
postLoader: this.dataLoaderService.createPostLoader(),
};
}
}
// In GraphQL config:
context: ({ req }) => {
const contextService = new GraphQLContextService(...);
return contextService.build();
}
Common Mistakes
Mistake 1: Sharing loaders across requests. If you cache a loader instance globally, user A's profile data leaks into user B's request. Always create new loaders per request.
Mistake 2: Forgetting to return results in the same order. DataLoader expects the batch function to return results in the exact same order as the input IDs. If you return them scrambled, the wrong user gets the wrong profile. Use a Map and iterate the input array to maintain order.
Mistake 3: Over-engineering with too many loaders. Create loaders only for relations you actually resolve in GraphQL. Don't create loaders for every possible join—that's premature optimization and adds complexity.
Mistake 4: Passing context incorrectly to child resolvers. Make sure the GraphQL context flows through to nested field resolvers. This is automatic in Apollo/NestJS GraphQL, but if you're manually constructing contexts, ensure child resolvers get the same context object.
Mistake 5: Using DataLoader for mutations that need fresh data. DataLoader caches within a request. If you mutate a user in the same request and then resolve their profile, you'll get stale data. Clear the loader cache after mutations, or use a separate loader instance for post-mutation reads.
Key Takeaways
- DataLoader for GraphQL N+1 solves the cascading query problem by batching IDs into single
INqueries. - Create one loader per relation; each loader exists only for one request, preventing data leaks.
- The batch function collects IDs, queries them together, and returns results in input order using a Map for fast lookup.
- Attach loaders to GraphQL context so all resolvers can access the same cached instances.
- Use REQUEST scope in NestJS to ensure fresh loader instances per HTTP request.
- Always maintain result order in batch functions and test with realistic query shapes (nested lists) to catch N+1 bugs.






Comments
Loading comments…