GraphQL path · Execution · Topic 4 of 12

The N+1 problem and DataLoader

Not started

The N+1 problem is what you get when a list field and a relationship field meet. The list resolver does one query and returns N items; the relationship field's resolver then runs once for each item, and each call does its own lookup. Nothing is wrong with any single resolver, which is why it slips into production. The usual fix is a **loader**: an object that resolvers ask for one key at a time, which waits until the current turn of the event loop finishes, then fetches all requested keys in one batch. Facebook's DataLoader library is the reference implementation, and most GraphQL servers in any language have an equivalent.

Why it matters: Field-by-field execution makes every relationship a loop of single lookups. A page of 50 posts with authors is 51 database queries until you add batching, and it is the first thing to check when a GraphQL API is slow.

  1. 1Where N+1 comes from

    One resolver loads a list (1 query). A child field's resolver then runs once per item, each doing its own lookup (N queries).

  2. 2Batch within a tick

    A loader collects every `load(key)` made in the same turn of the event loop, then calls your batch function once with all the keys.

  3. 3Same length, same order

    The batch function must return one result per key, in the order of the keys, using null or an Error for missing ones.

  4. 4One loader per request

    Loaders also cache by key. Create them per request in `context`, so one user's cached data never answers another user's request.

1

How it works

The explanation, with examples you can run.

Seeing it

⌘/Ctrl + Enter
OutputNot run yet

Press Run to see what your code prints, in order.

Runs in this browser, in an isolated frame with no access to this page. console.log, info, warn and error are shown; code runs as a classic script, so use an async function for await.

The naive resolver asks for u1 twice. The loader asks once, for three distinct ids, because the cache returns the same promise for a repeated key. This toy waits with setTimeout; DataLoader itself coalesces the loads made in one tick of the event loop (it schedules the batch after the promise jobs already queued, and lets you plug in your own scheduler), so loads made while one level of the query is being resolved end up in one batch, and the next level forms the next batch.

Using DataLoader

import DataLoader from "dataloader" function createContext(req) {  return {    user: authenticate(req),    loaders: {      user: new DataLoader(async (ids) => {        const rows = await sql`SELECT * FROM users WHERE id = ANY(${ids})`        const byId = new Map(rows.map((r) => [r.id, r]))        return ids.map((id) => byId.get(id) ?? null)   // one result per key, in key order      }),    },  }} const resolvers = {  Post: { author: (post, _args, ctx) => ctx.loaders.user.load(post.authorId) },}

Three rules matter. The batch function returns an array of the same length as the keys, in the same order. Loaders are created per request, because their cache is request-scoped memoisation: share one across requests and users see each other's data, or stale data forever. And batching reduces round trips, not work: a query that asks for ten thousand comments still loads ten thousand comments, which is why pagination and cost limits come next.

2

Check yourself

3 questions. Get them all right to finish this topic.

  1. 1.`{ posts(first: 50) { author { name } comments(first: 5) { author { name } } } }` with naive resolvers. Roughly how many queries?
  2. 2.Your batch function receives keys `[3, 1, 2]` and the database returns rows sorted by id, `[1, 2, 3]`. What must you do?
  3. 3.Why not create a single loader when the server starts and share it?

0 of 3 right so far. Get all three to finish this topic.