Solving N+1 with DataLoader

BackendGraphQLPerformanceAPI
Share on LinkedIn

The first GraphQL endpoint I shipped looked clean in the schema and returned correct data. Then someone queried 50 posts with their authors and comments, and Postgres logged 151 queries. One for posts, fifty for authors, fifty for comments. The resolvers were textbook — each field fetched its own data — and that was exactly the problem. DataLoader fixed it without rewriting the schema or denormalizing the API.

How N+1 sneaks in

GraphQL resolves fields depth-first. A query like:

query {
  posts(limit: 50) {
    title
    author { name }
    comments { body }
  }
}

Triggers:

  1. posts resolver → SELECT * FROM posts LIMIT 50 (1 query)
  2. author resolver × 50 → SELECT * FROM users WHERE id = ? (50 queries)
  3. comments resolver × 50 → SELECT * FROM comments WHERE post_id = ? (50 queries)

Total: 101 queries. Scale the list to 500 and you're drowning.

The resolver code looks innocent:

Post: {
  author: (post) => db.users.findById(post.authorId),
  comments: (post) => db.comments.findByPostId(post.postId),
}

Each call is correct in isolation. Together they're a performance disaster.

DataLoader mechanics

DataLoader wraps a batch function and a per-request cache:

const DataLoader = require('dataloader');

function createLoaders(db) {
  return {
    userById: new DataLoader(async (ids) => {
      const users = await db.users.findByIds(ids);
      const map = new Map(users.map(u => [u.id, u]));
      return ids.map(id => map.get(id) ?? null);
    }),
  };
}

Key rules:

  1. Batch function receives an array of keys — return results in the same order
  2. One loader per entity type — don't mix users and posts in one loader
  3. New loaders per request — attach to context, not a global singleton

The resolver becomes:

Post: {
  author: (post, _, { loaders }) => loaders.userById.load(post.authorId),
}

Fifty load() calls in the same event loop tick become one SELECT WHERE id IN (...).

Per-request scoping

Attach loaders to GraphQL context in your server setup:

const server = new ApolloServer({
  typeDefs,
  resolvers,
  context: ({ req }) => ({
    loaders: createLoaders(db),
    userId: req.user?.id,
  }),
});

For Express + graphql-http:

app.all('/graphql', createHandler({
  schema,
  context: (req) => ({ loaders: createLoaders(db) }),
}));

Never reuse loaders across requests — the cache would serve one user's data to another.

Batching across different resolvers

DataLoader deduplicates within a batch. If two posts share the same author, userById.load(sameId) hits the cache after the first load. You get one DB row, two resolver returns.

For nested lists (comments per post), use a composite key loader:

commentsByPostId: new DataLoader(async (postIds) => {
  const rows = await db.comments.findByPostIds(postIds);
  const grouped = groupBy(rows, 'postId');
  return postIds.map(id => grouped[id] ?? []);
}),

One query: SELECT * FROM comments WHERE post_id IN (...).

Java / Spring GraphQL

The Java DataLoader library works the same way:

@Bean
DataLoaderRegistry dataLoaderRegistry(UserRepository users) {
    DataLoader<Long, User> userLoader = DataLoader.newMappedDataLoader(
        ids -> users.findByIds(ids).thenApply(users ->
            ids.stream().collect(toMap(id -> id, users::get)))
    );
    return DataLoaderRegistry.newRegistry()
        .register("userById", userLoader)
        .build();
}

Spring GraphQL integrates via @BatchMapping:

@BatchMapping
public Map<Post, User> author(List<Post> posts) {
    Set<Long> ids = posts.stream().map(Post::authorId).collect(toSet());
    Map<Long, User> users = userRepository.findByIds(ids);
    return posts.stream().collect(toMap(p -> p, p -> users.get(p.authorId())));
}

Spring batches the list of posts automatically — same outcome, less boilerplate.

When DataLoader isn't enough

DataLoader fixes resolver-level N+1. It doesn't fix:

Profile first. Log query counts per request. DataLoader is the default fix for relational N+1, not the only tool.

Monitoring DataLoader efficiency

Track batch sizes in production to verify batching is working:

const loader = new DataLoader(async (ids) => {
  metrics.histogram('dataloader.batch_size', ids.length, { loader: 'userById' });
  const users = await db.users.findByIds(ids);
  // ...
});

If p50 batch size is 1, your resolvers aren't batching — likely an async timing issue where each resolver awaits before the next fires. Consider @graphql-tools/batch-execute or ensuring all resolvers in a level execute before any awaits resolve.

Common production mistakes

Teams get n plus one dataloader wrong in predictable ways:

GraphQL APIs for n plus one dataloader melt down under nested queries without depth limits, N+1 resolvers hit the database per field, and schema deprecation has no usage telemetry.

Debugging and triage workflow

When n plus one dataloader misbehaves in production, work top-down instead of guessing:

  1. Confirm scope — one tenant, region, or deployment stage? Narrow blast radius before deep diving.
  2. Check recent changes — deploys, flag flips, config pushes, and schema migrations in the last 24 hours.
  3. Compare golden signals — latency, error rate, saturation, and traffic for the affected surface vs. baseline.
  4. Reproduce minimally — smallest input or scenario that triggers the failure; capture traces/logs with correlation IDs.
  5. Fix forward or rollback — if rollback is faster than root-cause during incident, rollback first, postmortem second.
  6. Add a guard — alert, integration test, or circuit breaker so the same class of failure is caught earlier next time.

Document the timeline during triage. Future you (and on-call) will need timestamps, not just conclusions.

Resources

Frequently asked questions

What causes the N+1 problem in GraphQL?

When a list field resolver runs a separate database query for each item's nested field, you get 1 query for the list plus N queries for each child — hence N+1. GraphQL's field-by-field resolution model makes this easy to accidentally introduce because each resolver is independent and unaware of sibling calls.

How does DataLoader solve N+1?

DataLoader collects individual load requests during a single tick of the event loop, batches them into one call (e.g., SELECT WHERE id IN (...)), and caches results for the lifetime of the request. Resolvers stay simple — they call loader.load(id) — while the loader handles batching and deduplication.

Should DataLoader cache persist across requests?

No. Create a new DataLoader instance per request to avoid leaking data between users. The in-request cache prevents duplicate loads within the same query (e.g., the same author referenced twice), but it must be discarded when the request completes.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →