Fix: GraphQL N+1 Query Problem — DataLoader and Batching
Quick Answer
How to fix the GraphQL N+1 query problem — understanding why it happens, implementing DataLoader for batching, using query complexity limits, and selecting efficient resolver patterns.
The Problem
A GraphQL query that looks simple causes hundreds of database queries:
query {
posts { # 1 query
title
author { # N queries — one per post
name
}
}
}Server logs reveal the problem:
SELECT * FROM posts; -- 1 query
SELECT * FROM users WHERE id = 1; -- for post 1
SELECT * FROM users WHERE id = 2; -- for post 2
SELECT * FROM users WHERE id = 3; -- for post 3
... (97 more queries for 100 posts)100 posts = 101 total database queries. At 10ms each, that’s 1 second of pure database overhead for a single GraphQL request.
Or in application performance monitoring:
POST /graphql — 2340ms
SQL: SELECT * FROM posts (12ms)
SQL: SELECT * FROM users WHERE id = ? (8ms × 100 = 800ms)
SQL: SELECT * FROM comments WHERE post_id = ? (9ms × 100 = 900ms)Why This Happens
GraphQL resolvers are independent functions. Each field resolver fetches its own data without knowing what other resolvers at the same level are also fetching:
// Resolvers execute independently — no coordination between them
const resolvers = {
Query: {
posts: () => db.query('SELECT * FROM posts'), // 1 query
},
Post: {
// This resolver runs ONCE PER POST — not once total
author: (post) => db.query(
'SELECT * FROM users WHERE id = ?', [post.authorId]
),
// If there are 100 posts, this fires 100 times
},
};This is the N+1 problem: 1 query for the list + N queries for each item’s related data.
The problem compounds with nested relations:
query {
posts { # 1 query
author { # N queries
posts { # N×M queries — every author's posts
title
}
}
}
}Fix 1: Use DataLoader for Batching
DataLoader is the standard solution. It collects all load calls made during a single event loop tick and batches them into one query:
npm install dataloaderBasic DataLoader setup:
const DataLoader = require('dataloader');
// Batch function — receives an array of keys, returns an array of values in the same order
const userLoader = new DataLoader(async (userIds) => {
// One query for ALL needed user IDs
const users = await db.query(
'SELECT * FROM users WHERE id IN (?)', [userIds]
);
// IMPORTANT: Return values in the SAME ORDER as the input keys
// DataLoader matches results to requests by index
const userMap = new Map(users.map(u => [u.id, u]));
return userIds.map(id => userMap.get(id) || null);
});
// Resolver — calls loader.load() instead of querying directly
const resolvers = {
Post: {
author: (post) => userLoader.load(post.authorId),
// Instead of firing a query per post, DataLoader batches all .load() calls
// into a single SELECT * FROM users WHERE id IN (1, 2, 3, ...)
},
};How DataLoader batching works:
post1.authorresolver callsuserLoader.load(1)post2.authorresolver callsuserLoader.load(2)post3.authorresolver callsuserLoader.load(3)- … (all 100 posts call
load()in the same tick) - At the end of the tick, DataLoader calls the batch function with
[1, 2, 3, ..., 100] - One SQL query:
SELECT * FROM users WHERE id IN (1, 2, 3, ..., 100) - Results are distributed back to each resolver
Result: 101 queries → 2 queries.
Fix 2: Create Per-Request DataLoader Instances
DataLoader caches results within its lifetime. Using a single global DataLoader instance causes stale data across requests. Create a new loader per request:
// WRONG — global DataLoader caches across requests (stale data)
const userLoader = new DataLoader(batchUsers); // Global instance
// CORRECT — create loaders per request in context
const { ApolloServer } = require('@apollo/server');
const { expressMiddleware } = require('@apollo/server/express4');
const server = new ApolloServer({ typeDefs, resolvers });
app.use('/graphql', expressMiddleware(server, {
context: async ({ req }) => {
// Fresh DataLoader instances for each request
return {
loaders: {
user: new DataLoader(async (ids) => {
const users = await db.query('SELECT * FROM users WHERE id IN (?)', [ids]);
const map = new Map(users.map(u => [u.id, u]));
return ids.map(id => map.get(id) ?? null);
}),
comment: new DataLoader(async (postIds) => {
const comments = await db.query(
'SELECT * FROM comments WHERE post_id IN (?)', [postIds]
);
// Group comments by post_id
const grouped = postIds.map(postId =>
comments.filter(c => c.post_id === postId)
);
return grouped;
}),
},
};
},
}));
// Resolvers access loaders through context
const resolvers = {
Post: {
author: (post, _, context) => context.loaders.user.load(post.authorId),
comments: (post, _, context) => context.loaders.comment.load(post.id),
},
};Fix 3: DataLoader with Prisma
Prisma doesn’t have N+1 prevention built in for nested resolvers. Add DataLoader or use @prisma/dataloader:
npm install @prisma/dataloaderimport { PrismaClient } from '@prisma/client';
import { createPrismaRedisCache } from 'prisma-redis-middleware';
const prisma = new PrismaClient();
// Custom DataLoader with Prisma
import DataLoader from 'dataloader';
function createLoaders(prisma: PrismaClient) {
return {
user: new DataLoader<number, User | null>(async (ids) => {
const users = await prisma.user.findMany({
where: { id: { in: [...ids] } },
});
const map = new Map(users.map(u => [u.id, u]));
return ids.map(id => map.get(id) ?? null);
}),
postsByAuthor: new DataLoader<number, Post[]>(async (authorIds) => {
const posts = await prisma.post.findMany({
where: { authorId: { in: [...authorIds] } },
});
return authorIds.map(authorId =>
posts.filter(p => p.authorId === authorId)
);
}),
};
}
// In Apollo Server context
context: async ({ req }) => ({
prisma,
loaders: createLoaders(prisma),
}),Or use Prisma’s findMany with include for simple cases:
const resolvers = {
Query: {
// For simple cases, use Prisma's include to fetch relations in one query
posts: () => prisma.post.findMany({
include: {
author: true, // JOIN — fetches authors in same query
comments: true, // JOIN — fetches comments in same query
},
}),
},
};
// This works but fetches all fields — DataLoader is better for selective loadingFix 4: Use DataLoader with TypeORM
import DataLoader from 'dataloader';
import { In } from 'typeorm';
import { User } from './entities/User';
import { AppDataSource } from './data-source';
const userRepository = AppDataSource.getRepository(User);
function createUserLoader() {
return new DataLoader<number, User | null>(async (ids) => {
const users = await userRepository.findBy({ id: In([...ids]) });
const map = new Map(users.map(u => [u.id, u]));
return ids.map(id => map.get(id) ?? null);
});
}
// NestJS DataLoader with request scope
import { Injectable, Scope } from '@nestjs/common';
@Injectable({ scope: Scope.REQUEST }) // New instance per request
export class UserDataLoader {
readonly loader = new DataLoader<number, User | null>(async (ids) => {
const users = await this.userRepository.findBy({ id: In([...ids]) });
const map = new Map(users.map(u => [u.id, u]));
return ids.map(id => map.get(id) ?? null);
});
constructor(
@InjectRepository(User)
private userRepository: Repository<User>,
) {}
}Fix 5: Limit Query Complexity and Depth
The N+1 problem becomes catastrophic with deeply nested queries. Add query depth and complexity limits to prevent abuse:
const { ApolloServer } = require('@apollo/server');
const depthLimit = require('graphql-depth-limit');
const { createComplexityLimitRule } = require('graphql-validation-complexity');
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [
depthLimit(5), // Max 5 levels of nesting
createComplexityLimitRule(1000, {
// Each field costs 1 point; lists cost 10 points per item
scalarCost: 1,
objectCost: 2,
listFactor: 10,
}),
],
});Custom complexity per field:
type Query {
posts(limit: Int): [Post!]! @complexity(value: 1, multipliers: ["limit"])
users: [User!]! @complexity(value: 10)
}Block introspection in production to prevent schema enumeration (which lets attackers craft expensive queries):
const { ApolloServer } = require('@apollo/server');
const { NoIntrospection } = require('graphql');
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: process.env.NODE_ENV === 'production'
? [NoIntrospection]
: [],
});Fix 6: Use Persisted Queries or Query Whitelisting
For production APIs, allow only pre-approved queries. This eliminates the risk of costly ad-hoc queries entirely:
// Apollo Server — automatic persisted queries (APQ)
const { ApolloServer } = require('@apollo/server');
const { InMemoryLRUCache } = require('@apollo/utils.keyvaluecache');
const server = new ApolloServer({
typeDefs,
resolvers,
persistedQueries: {
cache: new InMemoryLRUCache({ maxSize: 100 * 1024 * 1024 }), // 100MB
},
});
// Client sends a hash of the query on first request
// Server stores and reuses it — prevents arbitrary query executionManual query allowlist:
const ALLOWED_QUERIES = new Set([
'query GetPosts { posts { title author { name } } }',
'query GetUser($id: ID!) { user(id: $id) { name email } }',
]);
app.use('/graphql', (req, res, next) => {
const query = req.body.query;
if (!ALLOWED_QUERIES.has(query)) {
return res.status(400).json({ error: 'Query not allowed' });
}
next();
});Fix 7: Monitor N+1 with Query Logging
Enable detailed SQL logging to detect N+1 patterns before they reach production:
// Log all queries in development
const knex = require('knex')({
client: 'pg',
connection: process.env.DATABASE_URL,
debug: process.env.NODE_ENV === 'development', // Logs all SQL
});
// Count queries per GraphQL request
let queryCount = 0;
const countingDb = {
query: (...args) => {
queryCount++;
console.log(`[Query #${queryCount}]`, args[0]);
return db.query(...args);
},
};
// Reset per request
app.use((req, res, next) => {
queryCount = 0;
res.on('finish', () => {
if (queryCount > 10) {
console.warn(`High query count: ${queryCount} for ${req.body?.operationName}`);
}
});
next();
});Use Apollo Studio or GraphQL Inspector to track query performance over time and identify resolvers with high execution counts.
Pro Tip: Set up an alert when a single GraphQL request fires more than 20 SQL queries. This threshold catches N+1 patterns in development before they reach production. Tools like
graphql-query-complexitycan enforce this at the validation layer.
Still Not Working?
Verify DataLoader is actually batching. Add logging to the batch function:
const userLoader = new DataLoader(async (ids) => {
console.log(`Batching ${ids.length} user IDs:`, ids);
// Should log once per request with all IDs, not once per resolver call
const users = await db.query('SELECT * FROM users WHERE id IN (?)', [ids]);
const map = new Map(users.map(u => [u.id, u]));
return ids.map(id => map.get(id) ?? null);
});If you see the batch function called once per resolver invocation (not batched), the DataLoader instance is being recreated per resolver call. Move the DataLoader instantiation to the request context.
DataLoader cache prevents seeing updated data — if data changes during a request (after a mutation), the DataLoader cache returns stale data. Clear the cache explicitly:
// After mutation, clear the cached value
userLoader.clear(updatedUserId);
// Or clear everything
userLoader.clearAll();Schema stitching and federation — if using Apollo Federation, each subgraph has its own resolvers and N+1 can occur at the gateway level. Use @key directives and reference resolvers correctly:
# In the Users subgraph
type User @key(fields: "id") {
id: ID!
name: String!
}
# The Posts subgraph references User by key
type Post {
authorId: ID!
author: User @provides(fields: "name")
}For related GraphQL issues, see Fix: GraphQL 400 Bad Request and Fix: Prisma Migration Failed.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: GraphQL Error Handling Not Working — Errors Not Returned or Always 200 OK
How to fix GraphQL error handling — error extensions, partial data with errors, Apollo formatError, custom error classes, client-side error detection, and network vs GraphQL errors.
Fix: Node.js JavaScript Heap Out of Memory
How to fix Node.js 'JavaScript heap out of memory' — increasing heap size, finding memory leaks with heap snapshots, fixing common leak patterns, and stream-based processing for large data.
Fix: Fastify Not Working — 404, Plugin Encapsulation, and Schema Validation Errors
How to fix Fastify issues — route 404 from plugin encapsulation, reply already sent, FST_ERR_VALIDATION, request.body undefined, @fastify/cors, hooks not running, and TypeScript type inference.
Fix: GraphQL Yoga Not Working — Schema Errors, Resolvers Not Executing, or Subscriptions Failing
How to fix GraphQL Yoga issues — schema definition, resolver patterns, context and authentication, file uploads, subscriptions with SSE, error handling, and Next.js integration.