A GraphQL post resolver with an `author` field that fetches the user per post. Classic GraphQL N+1. Mesrai caught it.
The vulnerable diff
// graphql/post-resolvers.ts
export const PostResolvers = {
Post: {
// BUG: runs once per Post — 100 posts = 100 user queries
author: async (post: Post) =>
db.users.findUnique({ where: { id: post.authorId } }),
comments: async (post: Post) =>
db.comments.findMany({ where: { postId: post.id } }), // also N+1
},
};What is wrong
GraphQL field resolvers run once per parent. A list query that fetches 100 posts then resolves the `author` field per post fires 100 user lookups — the worst-case shape of N+1. The standard fix is DataLoader: a per-request batch object that collects all `.load(id)` calls within an event-loop tick and issues one batched query (`WHERE id IN (...)`). Originally built at Facebook for exactly this pattern; now standard across GraphQL libraries.
The attack
Symptom: 100-post feed query takes 1.5s, mostly DB roundtrips:
# DataDog APM trace:
GET /graphql (1,420 ms total)
- resolve Post[] (15 ms)
- resolve Post.author × 100 (1,380 ms — 14 ms each)
- resolve Post.comments × 100 (... another 100 queries)
# With DataLoader:
GET /graphql (52 ms total)
- resolve Post[] (15 ms)
- userLoader.loadMany([100 ids]) → 1 query (22 ms)
- commentsByPostLoader (15 ms)Latency drops 25-30× on typical queries.
Mesrai's review comment
mesraipilot · Bot · reviewed 1 min ago
[mesrai] [code-review] [Performance] [GraphQL-N+1] [high]
GraphQL resolvers run per parent. `author` runs once per Post →
N queries per N posts. DataLoader to batch:
import DataLoader from "dataloader";
// Create per request, attached to context:
function createLoaders() {
return {
userById: new DataLoader<string, User | null>(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) ?? null);
}),
};
}
// In Apollo / yoga context:
context: ({ req }) => ({ user: req.user, loaders: createLoaders() })
// Resolver:
author: (post, _args, { loaders }) => loaders.userById.load(post.authorId),
Each request gets fresh loaders. Within a request all `.load(id)`
calls within a tick batch into one query.The fix
// graphql/post-resolvers.ts — fixed
export const PostResolvers = {
Post: {
author: (post, _args, { loaders }) => loaders.userById.load(post.authorId),
comments: (post, _args, { loaders }) => loaders.commentsByPostId.load(post.id),
},
};
// graphql/loaders.ts
export function createLoaders() {
return {
userById: new DataLoader<string, User | null>(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) ?? null);
}),
commentsByPostId: new DataLoader<string, Comment[]>(async (ids) => {
const all = await db.comments.findMany({ where: { postId: { in: [...ids] } } });
const byPost = new Map<string, Comment[]>();
for (const c of all) {
(byPost.get(c.postId) ?? byPost.set(c.postId, []).get(c.postId)!).push(c);
}
return ids.map(id => byPost.get(id) ?? []);
}),
};
}Per-request loaders attached to context. Each resolver hands its id to the loader; the loader batches within one event-loop tick into one query. 100-post query collapses from 101 queries to 3.
Why human review missed it
GraphQL N+1 is the most common performance bug in GraphQL APIs. Apollo and similar frameworks don't enforce DataLoader — it's convention. Mesrai catches every field resolver that issues a DB call without going through a loader.
Related rules + further reading
Mesrai rule pack: performance/graphql-dataloader-required — flags field resolvers calling DB directly.
GraphQL docs: DataLoader pattern.
Originally Facebook's solution to this exact bug class.
Takeaway
DataLoader per request. Resolvers call `.load(id)`. Single query per field. Mesrai catches every direct-DB resolver.