Skip to content
Codeloom
GraphQL

Solving the N+1 Problem in GraphQL with DataLoader

Learn how the DataLoader pattern batches and caches database calls to eliminate N+1 query performance issues in GraphQL APIs.

·6 min read · By Codeloom
Intermediate 12 min read

What you'll learn

  • What the N+1 problem is and why GraphQL is especially vulnerable to it
  • How DataLoader batches multiple database calls into a single query
  • How to implement per-request caching to avoid redundant lookups

Prerequisites

  • Basic GraphQL knowledge
  • Familiarity with Node.js and async/await

What Is the N+1 Problem?

The N+1 problem occurs when your application makes one query to fetch a list of items, then makes an additional query for each item to fetch related data. In REST APIs this is sometimes avoidable because the backend developer controls the response shape. In GraphQL, the client controls what fields are requested, so nested resolvers can silently trigger hundreds of database calls.

Consider a schema like this:

type Query {
  posts: [Post!]!
}

type Post {
  id: ID!
  title: String!
  author: User!
}

type User {
  id: ID!
  name: String!
}

When a client queries posts with author, the posts resolver runs once (1 query), but the author resolver runs once per post (N queries). If there are 50 posts, you execute 51 database queries. Many of those author queries may even fetch the same user repeatedly.

Why GraphQL Is Especially Vulnerable

GraphQL resolvers execute independently. Each field resolver has no knowledge of what other resolvers are doing or have already fetched. This isolation is a feature for composability but a liability for performance. The resolver for Post.author does not know that 30 other posts share the same author, and it cannot wait for sibling resolvers to coordinate.

Enter DataLoader

DataLoader, originally created by Facebook, solves this with two mechanisms: batching and caching.

Batching collects all individual load calls that happen within a single tick of the event loop and combines them into one batch function call. Instead of 50 separate SELECT * FROM users WHERE id = ? queries, DataLoader calls your batch function once with all 50 IDs.

Caching ensures that within a single request, if the same key is loaded twice, the second call returns the cached promise from the first call. This is a per-request memoization cache, not a shared application cache.

Installing DataLoader

npm install dataloader

Implementing a Basic DataLoader

First, define a batch function. This function receives an array of keys and must return a promise that resolves to an array of values in the same order:

import DataLoader from 'dataloader';

// Batch function: receives an array of user IDs
async function batchUsers(userIds) {
  // Single query for all requested users
  const users = await db.query(
    'SELECT * FROM users WHERE id = ANY($1)',
    [userIds]
  );

  // IMPORTANT: Results must be in the same order as the input keys
  const userMap = new Map(users.map(u => [u.id, u]));
  return userIds.map(id => userMap.get(id) || new Error(`User ${id} not found`));
}

const userLoader = new DataLoader(batchUsers);

The ordering requirement is critical. DataLoader matches results to callers by index position. If your database returns results in a different order (which most databases do), you must reorder them.

Integrating with GraphQL Resolvers

Create a new DataLoader instance per request. This is essential because the cache should not leak data between different users or requests:

import express from 'express';
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import DataLoader from 'dataloader';

const server = new ApolloServer({ typeDefs, resolvers });
await server.start();

app.use(
  '/graphql',
  expressMiddleware(server, {
    context: async ({ req }) => ({
      // Fresh DataLoader per request
      userLoader: 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));
      }),
    }),
  })
);

Now update the resolver to use the loader instead of querying directly:

const resolvers = {
  Query: {
    posts: () => db.posts.findAll(),
  },
  Post: {
    author: (post, _args, context) => {
      // This call is batched automatically
      return context.userLoader.load(post.authorId);
    },
  },
};

When GraphQL resolves 50 posts, all 50 calls to context.userLoader.load(post.authorId) are collected within the same event loop tick and dispatched as a single batch call.

Creating a Loader Factory

In real applications you will have many loaders. A common pattern is to create a factory that generates loaders:

function createLoaders() {
  return {
    userLoader: 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));
    }),
    commentLoader: new DataLoader(async (postIds) => {
      const comments = await db.comments.findByPostIds(postIds);
      const grouped = new Map();
      for (const comment of comments) {
        if (!grouped.has(comment.postId)) {
          grouped.set(comment.postId, []);
        }
        grouped.get(comment.postId).push(comment);
      }
      return postIds.map(id => grouped.get(id) || []);
    }),
  };
}

// In context creation
context: async ({ req }) => ({
  loaders: createLoaders(),
}),

Handling One-to-Many Relationships

For one-to-many relationships, the batch function groups results by the parent key:

const commentsByPostLoader = new DataLoader(async (postIds) => {
  const comments = await db.query(
    'SELECT * FROM comments WHERE post_id = ANY($1) ORDER BY created_at',
    [postIds]
  );

  const grouped = new Map(postIds.map(id => [id, []]));
  for (const comment of comments) {
    grouped.get(comment.postId).push(comment);
  }

  return postIds.map(id => grouped.get(id));
});

Common Pitfalls

Forgetting to Preserve Order

The most common bug is returning results from the batch function without reordering them to match the input keys. DataLoader will silently return wrong data if the order does not match.

Sharing Loaders Across Requests

Never reuse a DataLoader instance across multiple requests. The cache would leak data between users, causing authorization bugs. Always create loaders in the request context.

Misunderstanding the Cache Scope

DataLoader’s cache is not a performance cache like Redis. It is a per-request deduplication mechanism. It prevents fetching the same user twice within a single GraphQL operation, but it does not persist anything between requests.

Breaking the Event Loop Tick

DataLoader batches calls that happen in the same tick. If you use setTimeout or break the execution flow, calls may not be batched together. Keep your resolver code synchronous up to the loader.load() call.

Measuring the Impact

Add logging to your batch function to see the difference:

const userLoader = new DataLoader(async (ids) => {
  console.log(`Batched user query for ${ids.length} IDs: [${ids.join(', ')}]`);
  const users = await db.users.findByIds(ids);
  const map = new Map(users.map(u => [u.id, u]));
  return ids.map(id => map.get(id));
});

Before DataLoader you might see 50 individual queries. After DataLoader you should see one batch query with all 50 IDs, plus the deduplication means repeated IDs are only fetched once.

Summary

The N+1 problem is the most common performance issue in GraphQL APIs. DataLoader solves it elegantly by batching individual load calls into bulk queries and caching results within a request. The key rules are: create loaders per request, always preserve key order in your batch function, and never use DataLoader as a cross-request cache. With these patterns in place, your GraphQL API can handle deeply nested queries without drowning your database in redundant calls.