The N+1 Problem & DataLoader

GraphQL — The N+1 Problem & DataLoader
GraphQL
Chapter 5 · The N+1 Problem & DataLoader

🧮 The N+1 Problem & DataLoader

Chapter 3's resolver trace showed that Post.author's resolver runs once per post in a list. This chapter is about what that actually costs — GraphQL's own version of the N+1 problem already covered for Django and Rails' ORMs, and how a small utility called DataLoader fixes it.

The Problem, Illustrated

Take this query:

query { posts { title author { name } } }
  1. Query.posts's resolver runs once — one query, returning (say) 10 posts.
  2. Post.author's resolver then runs once per post — 10 separate calls, each issuing its own db.users.findById(...) lookup.

Total: 11 queries (1 + 10) for something that conceptually needs only 2 — one for all the posts, one batched lookup for every author those posts actually reference.

Why This Is Sneakier Than a Typical ORM N+1

A REST/ORM N+1 usually lives inside one endpoint's code a developer can eyeball directly. In GraphQL, the same Post.author resolver is reused across every query that touches it — its author has no visibility into how many posts any given client's query will actually return, since that's decided entirely by the query shape, not the resolver's own code.

The Fix Isn't "Write Better Resolvers"

A single resolver genuinely can't know in advance how many times it will run in a given request — the fix has to happen at a layer that CAN see all the calls made during one request: batching.

📦 DataLoader: Batching and Caching

DataLoader — built by the same team that created GraphQL — collects every individual .load(id) call made during a single tick of the event loop and merges them into one batched request:

Setting Up and Using a Loader

import DataLoader from "dataloader"; function createUserLoader() { return new DataLoader(async (ids) => { const users = await db.users.findByIds(ids); // ONE query for every id collected return ids.map(id => users.find(u => u.id === id)); // must match input order exactly }); } // Resolver, using the loader from context (Chapter 3's fourth parameter) Post: { author: (parent, args, context) => context.userLoader.load(parent.authorId), }

Naive Resolver vs DataLoader-Batched Resolver

Naive

db.users.findById(parent.authorId) runs immediately, once per post — 10 posts, 10 separate queries.

DataLoader

context.userLoader.load(parent.authorId) queues the ID; all 10 queued IDs are collected and resolved in exactly one batched call.

Caching Within a Request

DataLoader also caches: calling .load(42) twice in the same request returns the cached result the second time, without hitting the batch function again — deduplicating repeated lookups of the same ID within one query execution.

Batching ≠ Caching

Batching merges different IDs requested in the same tick into one call. Caching avoids re-fetching the same ID twice. DataLoader does both, but they're genuinely separate mechanisms.

One Loader Per Request, Not Global

A DataLoader instance must be created fresh inside whatever function builds the per-request context object — never shared across requests. The tip box below covers exactly why.

💻 Coding Challenges

Challenge 1: Batch the Post.comments Resolver

Rewrite Chapter 3's Post.comments resolver to use a DataLoader that batches by postId, given a db.comments.findByPostIds(postIds) function that returns comments for multiple posts in one query.

Goal: Practice writing a batch function whose return array matches the input ID order, when each key maps to a LIST of results rather than a single one.

→ Solution

Challenge 2: Count the Queries, Before and After

For a query returning 25 posts where every post shares one of only 4 distinct authors, state exactly how many database queries the naive resolver runs versus the DataLoader-batched version.

Goal: Practice quantifying the concrete savings, not just describing them abstractly.

→ Solution

Challenge 3: Diagnose a Cross-Request Cache Leak

A codebase creates one DataLoader instance as a module-level singleton, shared across every request, instead of creating a new one per request. Explain what can go wrong, especially in an app with per-user data access rules.

Goal: Practice reasoning about why request-scoping matters, not just batching mechanics.

→ Solution

⚠️ Gotcha: A Shared, Global DataLoader Instance

It's tempting to create one DataLoader at startup and reuse it for every request — it seems more efficient than constructing a new one each time. It isn't safe: DataLoader's cache has no concept of "which request" or "which user" a cached result belongs to. If User A's request caches a lookup, and User B's request later calls .load() with the same ID, User B gets User A's cached result back — potentially leaking data across users, or serving stale data indefinitely since the cache never expires on its own. A DataLoader instance must be created fresh inside whatever function builds the per-request context object from Chapter 3, so its cache lives and dies with exactly one request.

🎯 What's Next

With performance handled, the next chapter puts everything together into a real, running server: Building a GraphQL Server with Node.js — Apollo Server or graphql-yoga, wiring resolvers and DataLoaders to an actual data source.