🔥 0
0
Lesson 4 of 6 18 min +250 XP

The N+1 Problem & DataLoader

The N+1 problem is the most common performance pitfall in GraphQL. Understanding and fixing it is essential for production GraphQL APIs.

What is the N+1 Problem?

Consider this query:

query {
  posts {      # 1 query to get all posts
    title
    author {   # N queries - one for each post's author!
      name
    }
  }
}
The Problem

If you have 100 posts, this executes 101 database queries: 1 to get posts + 100 to get each author. Even worse, many posts might have the same author!

Let's See It in Action

// Naive resolver - causes N+1
const resolvers = {
  Query: {
    posts: () => db.query('SELECT * FROM posts')  // 1 query
  },

  Post: {
    author: (post) => {
      // Called once for EACH post!
      console.log(`Fetching author ${post.authorId}`);
      return db.query('SELECT * FROM users WHERE id = ?', post.authorId);
    }
  }
};

Server logs with 5 posts:

Fetching author 1
Fetching author 2
Fetching author 1  ← Same author, fetched again!
Fetching author 3
Fetching author 1  ← And again!
6
Database queries
3
Duplicate fetches
2
Queries needed

Enter DataLoader

[DataLoader](https://github.com/graphql/dataloader) is a utility that:

  • Batches - Collects all IDs requested in a single tick
  • Deduplicates - Removes duplicate IDs
  • Caches - Stores results for the duration of the request
import DataLoader from 'dataloader';

// Batch function - receives array of IDs, returns array of results
const userLoader = new DataLoader(async (userIds) => {
  console.log(`Batched fetch for users: ${userIds}`);

  // One query for all users!
  const users = await db.query(
    'SELECT * FROM users WHERE id IN (?)',
    [userIds]
  );

  // IMPORTANT: Return results in the same order as input IDs
  return userIds.map(id => users.find(user => user.id === id));
});

Using DataLoader in Resolvers

const resolvers = {
  Query: {
    posts: () => db.query('SELECT * FROM posts')
  },

  Post: {
    author: (post, _, context) => {
      // Use the loader instead of direct DB query
      return context.loaders.userLoader.load(post.authorId);
    }
  }
};

Server logs with 5 posts (same query):

Batched fetch for users: [1, 2, 3]  ← Just ONE query with unique IDs!
2
Database queries
0
Duplicate fetches
67%
Reduction

How DataLoader Works

// Tick 1: Resolvers call .load()
author resolver for post 1: loader.load(1)
author resolver for post 2: loader.load(2)
author resolver for post 3: loader.load(1) // duplicate
author resolver for post 4: loader.load(3)
author resolver for post 5: loader.load(1) // duplicate
// Tick 2: DataLoader batches and executes
Collected IDs: [1, 2, 1, 3, 1]
Deduplicated: [1, 2, 3]
Batch function called with: [1, 2, 3]
// Tick 3: Results distributed to waiting resolvers
Each .load() promise resolves with its user

Setting Up DataLoader Per-Request

Critical: Create new loaders for each request to prevent data leaking between users:
// loaders.js
import DataLoader from 'dataloader';

export function createLoaders(db) {
  return {
    userLoader: new DataLoader(async (ids) => {
      const users = await db.users.findByIds(ids);
      return ids.map(id => users.find(u => u.id === id) || null);
    }),

    postLoader: new DataLoader(async (ids) => {
      const posts = await db.posts.findByIds(ids);
      return ids.map(id => posts.find(p => p.id === id) || null);
    }),

    // Loader for one-to-many relationships
    postsByAuthorLoader: new DataLoader(async (authorIds) => {
      const posts = await db.posts.findByAuthorIds(authorIds);
      // Group posts by author
      return authorIds.map(authorId =>
        posts.filter(p => p.authorId === authorId)
      );
    })
  };
}

// server.js
const server = new ApolloServer({
  typeDefs,
  resolvers,
  context: ({ req }) => ({
    user: getUser(req),
    loaders: createLoaders(db)  // New loaders per request!
  })
});

Batch Function Rules

The batch function must follow these rules:

1. RETURN ARRAY SAME LENGTH

If you receive 5 IDs, return 5 results (use null for missing)

2. MAINTAIN ORDER

Results must be in same order as input IDs

// Correct batch function
const userLoader = new DataLoader(async (ids) => {
  const users = await db.query(
    'SELECT * FROM users WHERE id IN (?)',
    [ids]
  );

  // Create a map for O(1) lookup
  const userMap = new Map(users.map(u => [u.id, u]));

  // Return in correct order, null for missing
  return ids.map(id => userMap.get(id) || null);
});

Real-World Example

// Complete resolver setup with DataLoader
import { ApolloServer, gql } from 'apollo-server';
import DataLoader from 'dataloader';

const typeDefs = gql`
  type User {
    id: ID!
    name: String!
    posts: [Post!]!
  }

  type Post {
    id: ID!
    title: String!
    author: User!
    comments: [Comment!]!
  }

  type Comment {
    id: ID!
    text: String!
    author: User!
  }

  type Query {
    posts: [Post!]!
  }
`;

// Simulated database
const db = {
  users: [
    { id: '1', name: 'Sarah' },
    { id: '2', name: 'Alex' },
    { id: '3', name: 'Jordan' }
  ],
  posts: [
    { id: 'p1', title: 'GraphQL 101', authorId: '1' },
    { id: 'p2', title: 'DataLoader Tips', authorId: '1' },
    { id: 'p3', title: 'REST APIs', authorId: '2' }
  ],
  comments: [
    { id: 'c1', text: 'Great post!', postId: 'p1', authorId: '2' },
    { id: 'c2', text: 'Thanks!', postId: 'p1', authorId: '1' },
    { id: 'c3', text: 'Helpful', postId: 'p2', authorId: '3' }
  ]
};

function createLoaders() {
  return {
    user: new DataLoader(async (ids) => {
      console.log(`Batch loading users: ${ids}`);
      return ids.map(id => db.users.find(u => u.id === id));
    }),

    postsByAuthor: new DataLoader(async (authorIds) => {
      console.log(`Batch loading posts for authors: ${authorIds}`);
      return authorIds.map(authorId =>
        db.posts.filter(p => p.authorId === authorId)
      );
    }),

    commentsByPost: new DataLoader(async (postIds) => {
      console.log(`Batch loading comments for posts: ${postIds}`);
      return postIds.map(postId =>
        db.comments.filter(c => c.postId === postId)
      );
    })
  };
}

const resolvers = {
  Query: {
    posts: () => db.posts
  },

  Post: {
    author: (post, _, { loaders }) => loaders.user.load(post.authorId),
    comments: (post, _, { loaders }) => loaders.commentsByPost.load(post.id)
  },

  Comment: {
    author: (comment, _, { loaders }) => loaders.user.load(comment.authorId)
  },

  User: {
    posts: (user, _, { loaders }) => loaders.postsByAuthor.load(user.id)
  }
};

const server = new ApolloServer({
  typeDefs,
  resolvers,
  context: () => ({ loaders: createLoaders() })
});

Before vs After

Query Without DataLoader With DataLoader
10 posts with authors 11 queries 2 queries
100 posts with authors 101 queries 2 queries
100 posts + comments + authors 300+ queries 4 queries

Key Takeaways

  • N+1 problem - Fetching a list then N additional queries for related data
  • DataLoader batches - Collects all IDs in a tick, makes one query
  • DataLoader dedupes - Same ID requested twice = one fetch
  • Per-request instances - Create new loaders in context for each request
  • Order matters - Batch function must return results in same order as input IDs

Next up: When to Use GraphQL - Learn the decision framework for choosing between GraphQL and REST.

🧠 Quick Quiz

Test your understanding of this lesson.

1

What is the N+1 problem in GraphQL?

2

How does DataLoader solve the N+1 problem?

3

When should you create a new DataLoader instance?