From harness-claude
> Master N+1 query elimination — detecting N+1 patterns in ORMs and GraphQL resolvers, eager loading strategies, DataLoader batching, query count monitoring, and ORM-specific solutions for Prisma, Drizzle, Sequelize, and TypeORM.
npx claudepluginhub intense-visions/harness-engineering --plugin harness-claudeThis skill uses the workspace's default tool permissions.
> Master N+1 query elimination — detecting N+1 patterns in ORMs and GraphQL resolvers, eager loading strategies, DataLoader batching, query count monitoring, and ORM-specific solutions for Prisma, Drizzle, Sequelize, and TypeORM.
Implements DataLoader pattern in GraphQL to batch and cache database fetches per request, eliminating N+1 queries in resolvers for lists and shared data.
Detects N+1 query problems in PHP code including queries in loops, missing eager loading, lazy loading abuse, and relationship traversal issues.
Optimizes GraphQL API performance with query complexity analysis, batching, caching strategies, depth limiting, monitoring, tracing, and database query optimization.
Share bugs, ideas, or general feedback.
Master N+1 query elimination — detecting N+1 patterns in ORMs and GraphQL resolvers, eager loading strategies, DataLoader batching, query count monitoring, and ORM-specific solutions for Prisma, Drizzle, Sequelize, and TypeORM.
Identify the N+1 pattern. An N+1 occurs when code fetches a list (1 query) then fetches related data for each item individually (N queries):
// N+1 PROBLEM: 1 query for posts + N queries for authors
const posts = await db.query('SELECT * FROM posts LIMIT 50'); // 1 query
for (const post of posts) {
post.author = await db.query(
// 50 queries!
'SELECT * FROM users WHERE id = $1',
[post.author_id]
);
}
// Total: 51 queries for 50 posts
Fix with JOIN (single query). The most performant solution collapses N+1 into a single query:
-- SOLUTION 1: JOIN — single query, all data
SELECT p.*, u.name AS author_name, u.avatar AS author_avatar
FROM posts p
JOIN users u ON u.id = p.author_id
ORDER BY p.created_at DESC
LIMIT 50;
-- Total: 1 query
Fix with eager loading in ORMs. Each ORM has its own syntax for including relations:
// Prisma: include related data
const posts = await prisma.post.findMany({
take: 50,
orderBy: { createdAt: 'desc' },
include: {
author: { select: { name: true, avatar: true } },
tags: true,
_count: { select: { comments: true } },
},
});
// Prisma generates: 1 query for posts + 1 query for authors + 1 query for tags
// Total: 3 queries (not 51+)
// Drizzle: relational query
const posts = await db.query.posts.findMany({
limit: 50,
orderBy: [desc(posts.createdAt)],
with: {
author: { columns: { name: true, avatar: true } },
tags: true,
},
});
// TypeORM: eager relations or query builder
const posts = await postRepo.find({
take: 50,
order: { createdAt: 'DESC' },
relations: ['author', 'tags'],
});
Fix with batch loading using IN clause. When a JOIN is impractical, batch the N queries into one:
// SOLUTION 2: Batch with IN clause — 2 queries instead of N+1
const posts = await db.query('SELECT * FROM posts LIMIT 50');
const authorIds = [...new Set(posts.map((p) => p.author_id))];
const authors = await db.query('SELECT * FROM users WHERE id = ANY($1)', [authorIds]);
const authorMap = new Map(authors.map((a) => [a.id, a]));
for (const post of posts) {
post.author = authorMap.get(post.author_id);
}
// Total: 2 queries regardless of N
Implement DataLoader for GraphQL resolvers. DataLoader automatically batches individual loads within a single tick:
import DataLoader from 'dataloader';
// Create a DataLoader that batches user lookups
const userLoader = new DataLoader<string, User>(async (userIds) => {
const users = await db.query('SELECT * FROM users WHERE id = ANY($1)', [userIds]);
const userMap = new Map(users.map((u) => [u.id, u]));
return userIds.map((id) => userMap.get(id) || null);
});
// GraphQL resolver — each call is batched automatically
const resolvers = {
Post: {
author: (post) => userLoader.load(post.authorId),
// 50 posts → 50 calls to userLoader.load()
// DataLoader batches into 1 query: WHERE id = ANY([...50 ids])
},
};
// IMPORTANT: create a new DataLoader per request to prevent cache leaks
function createLoaders() {
return {
userLoader: new DataLoader(batchUsers),
postLoader: new DataLoader(batchPosts),
};
}
Detect N+1 queries in development. Add query counting to catch N+1 patterns before production:
// Prisma: enable query logging
const prisma = new PrismaClient({
log: [{ emit: 'event', level: 'query' }],
});
let queryCount = 0;
prisma.$on('query', () => {
queryCount++;
});
// After handling a request:
if (queryCount > 10) {
console.warn(`N+1 alert: ${queryCount} queries in single request`);
}
// Express middleware for query counting
function queryCounter(req, res, next) {
req.queryCount = 0;
const origQuery = db.query.bind(db);
db.query = (...args) => {
req.queryCount++;
return origQuery(...args);
};
res.on('finish', () => {
if (req.queryCount > 10) {
console.warn(`${req.method} ${req.path}: ${req.queryCount} queries`);
}
});
next();
}
Handle nested N+1 (N+1+1). N+1 can cascade across multiple relation levels:
// N+1+1: posts → authors → departments
// Bad: 1 + 50 + 50 = 101 queries
const posts = await getPosts();
for (const post of posts) {
post.author = await getUser(post.authorId); // N queries
post.author.dept = await getDept(post.author.deptId); // N more queries
}
// Good: eager load all levels at once
const posts = await prisma.post.findMany({
include: {
author: {
include: { department: true }, // nested eager load
},
},
});
// Total: 3 queries (posts, authors, departments)
Each database query incurs fixed overhead: network round-trip (~0.5-2ms on localhost, 5-50ms cross-region), query parsing, plan generation, and connection acquisition from the pool. For 50 items, an N+1 pattern adds 25-100ms on localhost or 250-2500ms cross-region in pure overhead, before any actual data work. The queries are individually fast (each returns 1 row), which is why they do not appear in slow query logs. The problem is cumulative overhead.
DataLoader collects all .load() calls made during a single tick of the event loop (using process.nextTick). At the end of the tick, it calls the batch function with all collected keys. This means DataLoader only works when all loads are initiated synchronously or within the same microtask. If loads are spread across multiple awaits, they may not batch together. For GraphQL resolvers, this works naturally because all field resolvers for a single type run in the same tick.
Shopify's GraphQL Admin API uses DataLoader extensively to batch relationship resolution. A query fetching 50 orders with their line items, customers, and shipping addresses resolves in 4 database queries (one per entity type) instead of 201 (1 + 50 + 50 + 50 + 50). Each DataLoader is created per-request and destroyed afterward to prevent cross-request cache pollution. They instrument DataLoader batch sizes to alert when a batch exceeds 1000 IDs, indicating a query pattern that might benefit from a JOIN instead.
GitHub's issue list API loads issues with their labels, assignees, and milestone. Early versions exhibited N+1 when loading labels (each issue triggered a separate labels query). They resolved it with a batch loader that collects all issue IDs and executes a single SELECT * FROM issue_labels WHERE issue_id = ANY($1) query. The result is mapped back to each issue using an in-memory index. Total queries for a 30-issue page: 4 (issues, labels, assignees, milestones) regardless of how many items have labels.
Over-eager loading. Including every relation "just in case" causes JOINs across many tables, producing cartesian products with massive result sets. Only include relations you actually need for the current view.
Caching individual DataLoader results across requests. DataLoader's built-in cache is per-request by design. Sharing a DataLoader across requests serves stale data and leaks memory. Always create new DataLoader instances per request.
Assuming ORMs prevent N+1. ORMs make N+1 easy to create by default (lazy loading). Accessing a relation property in a loop triggers individual queries. Always use explicit eager loading (include, with, relations) for list endpoints.
Batch size without limits. A DataLoader batch of 10,000 IDs generates a massive IN clause that the database must parse and plan. Set maxBatchSize on DataLoader (e.g., 100-500) and implement chunked batching for larger sets.