Fixing the N+1 Problem in GraphQL Resolvers with Prisma
Why a single GraphQL query can fire hundreds of database round-trips, how to see it happening, and the two fixes that actually work.
- graphql
- prisma
- node
- performance
A GraphQL API feels fast in development and falls over in production for one reason more often than any other: the resolver graph turns one client request into hundreds of database queries. It is called the N+1 problem, and it is not a Prisma bug or an Apollo bug. It is a direct consequence of how resolvers are executed.
Why it happens
GraphQL resolves field by field. A query for a course list with each course's author looks like one request to the client:
query {
courses {
id
title
author {
name
}
}
}The server runs the courses resolver once, gets back N courses, then runs the
author resolver once per course. If that field resolver looks like this:
const resolvers = {
Course: {
author: (course) =>
prisma.user.findUnique({ where: { id: course.authorId } }),
},
}…then twenty courses means one query for the list plus twenty queries for
authors. Twenty-one round-trips for data that could have come back in two. Add
a nested enrollments field and you are multiplying, not adding.
The tell is that latency scales with result count rather than payload size. A page that is fine with ten rows and unusable with two hundred is almost always this.
See it before you fix it
Do not guess. Turn on Prisma's query logging and count what one request emits:
const prisma = new PrismaClient({
log: [{ emit: 'event', level: 'query' }],
})
prisma.$on('query', (e) => {
console.log(e.query, e.duration)
})Load the page once, count the lines. If you see the same SELECT with a
different id parameter repeated N times, you have found it.
Fix one: batch with DataLoader
DataLoader collects every key requested within a single tick of the event loop, calls your batch function once with all of them, and hands each caller back its own result.
import DataLoader from 'dataloader'
const createUserLoader = () =>
new DataLoader(async (ids: readonly string[]) => {
const users = await prisma.user.findMany({
where: { id: { in: [...ids] } },
})
const byId = new Map(users.map((u) => [u.id, u]))
return ids.map((id) => byId.get(id) ?? null)
})Two rules make this correct rather than subtly broken:
- Return results in the same order as the keys. The batch function must
return an array the same length as
ids, positionally aligned. A databaseINclause gives no ordering guarantee, so map through the keys — never return the raw query result. - Create loaders per request, not per process. A module-level loader is a cache shared between users. Build the loaders inside your context function so each request gets a fresh, empty cache.
const context = async () => ({ loaders: { user: createUserLoader() } })The resolver becomes (course, _args, ctx) => ctx.loaders.user.load(course.authorId)
and twenty-one queries become two.
Fix two: resolve from the parent
DataLoader is the general answer, but sometimes the cheaper one is to stop having a field resolver at all. If the parent query already knows the child is needed, fetch it there:
prisma.course.findMany({ include: { author: true } })Now the default resolver reads course.author off the object already in
memory and never touches the database. This is the right call when the relation
is small, always requested, and one level deep. It is the wrong call when it is
not always requested — you have traded N+1 for over-fetching on every request.
Which to reach for
Use include for relations your UI always renders. Use DataLoader for
everything deeper, everything optional, and anything reachable from more than
one parent type. Then re-run the query log and confirm the count actually
dropped — the fix is only real when you can see it in the logs.