The Code Dealer Logo
All articles

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.

The Code Dealer
  • 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:

  1. Return results in the same order as the keys. The batch function must return an array the same length as ids, positionally aligned. A database IN clause gives no ordering guarantee, so map through the keys — never return the raw query result.
  2. 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.