Skip to content

Fix: GraphQL N+1 Query Problem — DataLoader and Batching

FixDevs ·

Quick Answer

How to fix the GraphQL N+1 query problem — understanding why it happens, implementing DataLoader for batching, using query complexity limits, and selecting efficient resolver patterns.

The Problem

A GraphQL query that looks simple causes hundreds of database queries:

query {
  posts {          # 1 query
    title
    author {       # N queries — one per post
      name
    }
  }
}

Server logs reveal the problem:

SELECT * FROM posts;                          -- 1 query
SELECT * FROM users WHERE id = 1;             -- for post 1
SELECT * FROM users WHERE id = 2;             -- for post 2
SELECT * FROM users WHERE id = 3;             -- for post 3
... (97 more queries for 100 posts)

100 posts = 101 total database queries. At 10ms each, that’s 1 second of pure database overhead for a single GraphQL request.

Or in application performance monitoring:

POST /graphql  — 2340ms
  SQL: SELECT * FROM posts (12ms)
  SQL: SELECT * FROM users WHERE id = ? (8ms × 100 = 800ms)
  SQL: SELECT * FROM comments WHERE post_id = ? (9ms × 100 = 900ms)

Why This Happens

GraphQL resolvers are independent functions. Each field resolver fetches its own data without knowing what other resolvers at the same level are also fetching:

// Resolvers execute independently — no coordination between them
const resolvers = {
  Query: {
    posts: () => db.query('SELECT * FROM posts'),  // 1 query
  },
  Post: {
    // This resolver runs ONCE PER POST — not once total
    author: (post) => db.query(
      'SELECT * FROM users WHERE id = ?', [post.authorId]
    ),
    // If there are 100 posts, this fires 100 times
  },
};

This is the N+1 problem: 1 query for the list + N queries for each item’s related data.

The problem compounds with nested relations:

query {
  posts {        # 1 query
    author {     # N queries
      posts {    # N×M queries — every author's posts
        title
      }
    }
  }
}

Fix 1: Use DataLoader for Batching

DataLoader is the standard solution. It collects all load calls made during a single event loop tick and batches them into one query:

npm install dataloader

Basic DataLoader setup:

const DataLoader = require('dataloader');

// Batch function — receives an array of keys, returns an array of values in the same order
const userLoader = new DataLoader(async (userIds) => {
  // One query for ALL needed user IDs
  const users = await db.query(
    'SELECT * FROM users WHERE id IN (?)', [userIds]
  );

  // IMPORTANT: Return values in the SAME ORDER as the input keys
  // DataLoader matches results to requests by index
  const userMap = new Map(users.map(u => [u.id, u]));
  return userIds.map(id => userMap.get(id) || null);
});

// Resolver — calls loader.load() instead of querying directly
const resolvers = {
  Post: {
    author: (post) => userLoader.load(post.authorId),
    // Instead of firing a query per post, DataLoader batches all .load() calls
    // into a single SELECT * FROM users WHERE id IN (1, 2, 3, ...)
  },
};

How DataLoader batching works:

  1. post1.author resolver calls userLoader.load(1)
  2. post2.author resolver calls userLoader.load(2)
  3. post3.author resolver calls userLoader.load(3)
  4. … (all 100 posts call load() in the same tick)
  5. At the end of the tick, DataLoader calls the batch function with [1, 2, 3, ..., 100]
  6. One SQL query: SELECT * FROM users WHERE id IN (1, 2, 3, ..., 100)
  7. Results are distributed back to each resolver

Result: 101 queries → 2 queries.

Fix 2: Create Per-Request DataLoader Instances

DataLoader caches results within its lifetime. Using a single global DataLoader instance causes stale data across requests. Create a new loader per request:

// WRONG — global DataLoader caches across requests (stale data)
const userLoader = new DataLoader(batchUsers);  // Global instance

// CORRECT — create loaders per request in context
const { ApolloServer } = require('@apollo/server');
const { expressMiddleware } = require('@apollo/server/express4');

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

app.use('/graphql', expressMiddleware(server, {
  context: async ({ req }) => {
    // Fresh DataLoader instances for each request
    return {
      loaders: {
        user: new DataLoader(async (ids) => {
          const users = await db.query('SELECT * FROM users WHERE id IN (?)', [ids]);
          const map = new Map(users.map(u => [u.id, u]));
          return ids.map(id => map.get(id) ?? null);
        }),

        comment: new DataLoader(async (postIds) => {
          const comments = await db.query(
            'SELECT * FROM comments WHERE post_id IN (?)', [postIds]
          );
          // Group comments by post_id
          const grouped = postIds.map(postId =>
            comments.filter(c => c.post_id === postId)
          );
          return grouped;
        }),
      },
    };
  },
}));

// Resolvers access loaders through context
const resolvers = {
  Post: {
    author: (post, _, context) => context.loaders.user.load(post.authorId),
    comments: (post, _, context) => context.loaders.comment.load(post.id),
  },
};

Fix 3: DataLoader with Prisma

Prisma doesn’t have N+1 prevention built in for nested resolvers. Add DataLoader or use @prisma/dataloader:

npm install @prisma/dataloader
import { PrismaClient } from '@prisma/client';
import { createPrismaRedisCache } from 'prisma-redis-middleware';

const prisma = new PrismaClient();

// Custom DataLoader with Prisma
import DataLoader from 'dataloader';

function createLoaders(prisma: PrismaClient) {
  return {
    user: new DataLoader<number, User | null>(async (ids) => {
      const users = await prisma.user.findMany({
        where: { id: { in: [...ids] } },
      });
      const map = new Map(users.map(u => [u.id, u]));
      return ids.map(id => map.get(id) ?? null);
    }),

    postsByAuthor: new DataLoader<number, Post[]>(async (authorIds) => {
      const posts = await prisma.post.findMany({
        where: { authorId: { in: [...authorIds] } },
      });
      return authorIds.map(authorId =>
        posts.filter(p => p.authorId === authorId)
      );
    }),
  };
}

// In Apollo Server context
context: async ({ req }) => ({
  prisma,
  loaders: createLoaders(prisma),
}),

Or use Prisma’s findMany with include for simple cases:

const resolvers = {
  Query: {
    // For simple cases, use Prisma's include to fetch relations in one query
    posts: () => prisma.post.findMany({
      include: {
        author: true,       // JOIN — fetches authors in same query
        comments: true,     // JOIN — fetches comments in same query
      },
    }),
  },
};
// This works but fetches all fields — DataLoader is better for selective loading

Fix 4: Use DataLoader with TypeORM

import DataLoader from 'dataloader';
import { In } from 'typeorm';
import { User } from './entities/User';
import { AppDataSource } from './data-source';

const userRepository = AppDataSource.getRepository(User);

function createUserLoader() {
  return new DataLoader<number, User | null>(async (ids) => {
    const users = await userRepository.findBy({ id: In([...ids]) });
    const map = new Map(users.map(u => [u.id, u]));
    return ids.map(id => map.get(id) ?? null);
  });
}

// NestJS DataLoader with request scope
import { Injectable, Scope } from '@nestjs/common';

@Injectable({ scope: Scope.REQUEST })  // New instance per request
export class UserDataLoader {
  readonly loader = new DataLoader<number, User | null>(async (ids) => {
    const users = await this.userRepository.findBy({ id: In([...ids]) });
    const map = new Map(users.map(u => [u.id, u]));
    return ids.map(id => map.get(id) ?? null);
  });

  constructor(
    @InjectRepository(User)
    private userRepository: Repository<User>,
  ) {}
}

Fix 5: Limit Query Complexity and Depth

The N+1 problem becomes catastrophic with deeply nested queries. Add query depth and complexity limits to prevent abuse:

const { ApolloServer } = require('@apollo/server');
const depthLimit = require('graphql-depth-limit');
const { createComplexityLimitRule } = require('graphql-validation-complexity');

const server = new ApolloServer({
  typeDefs,
  resolvers,
  validationRules: [
    depthLimit(5),  // Max 5 levels of nesting
    createComplexityLimitRule(1000, {
      // Each field costs 1 point; lists cost 10 points per item
      scalarCost: 1,
      objectCost: 2,
      listFactor: 10,
    }),
  ],
});

Custom complexity per field:

type Query {
  posts(limit: Int): [Post!]! @complexity(value: 1, multipliers: ["limit"])
  users: [User!]!              @complexity(value: 10)
}

Block introspection in production to prevent schema enumeration (which lets attackers craft expensive queries):

const { ApolloServer } = require('@apollo/server');
const { NoIntrospection } = require('graphql');

const server = new ApolloServer({
  typeDefs,
  resolvers,
  validationRules: process.env.NODE_ENV === 'production'
    ? [NoIntrospection]
    : [],
});

Fix 6: Use Persisted Queries or Query Whitelisting

For production APIs, allow only pre-approved queries. This eliminates the risk of costly ad-hoc queries entirely:

// Apollo Server — automatic persisted queries (APQ)
const { ApolloServer } = require('@apollo/server');
const { InMemoryLRUCache } = require('@apollo/utils.keyvaluecache');

const server = new ApolloServer({
  typeDefs,
  resolvers,
  persistedQueries: {
    cache: new InMemoryLRUCache({ maxSize: 100 * 1024 * 1024 }), // 100MB
  },
});

// Client sends a hash of the query on first request
// Server stores and reuses it — prevents arbitrary query execution

Manual query allowlist:

const ALLOWED_QUERIES = new Set([
  'query GetPosts { posts { title author { name } } }',
  'query GetUser($id: ID!) { user(id: $id) { name email } }',
]);

app.use('/graphql', (req, res, next) => {
  const query = req.body.query;
  if (!ALLOWED_QUERIES.has(query)) {
    return res.status(400).json({ error: 'Query not allowed' });
  }
  next();
});

Fix 7: Monitor N+1 with Query Logging

Enable detailed SQL logging to detect N+1 patterns before they reach production:

// Log all queries in development
const knex = require('knex')({
  client: 'pg',
  connection: process.env.DATABASE_URL,
  debug: process.env.NODE_ENV === 'development',  // Logs all SQL
});

// Count queries per GraphQL request
let queryCount = 0;

const countingDb = {
  query: (...args) => {
    queryCount++;
    console.log(`[Query #${queryCount}]`, args[0]);
    return db.query(...args);
  },
};

// Reset per request
app.use((req, res, next) => {
  queryCount = 0;
  res.on('finish', () => {
    if (queryCount > 10) {
      console.warn(`High query count: ${queryCount} for ${req.body?.operationName}`);
    }
  });
  next();
});

Use Apollo Studio or GraphQL Inspector to track query performance over time and identify resolvers with high execution counts.

Pro Tip: Set up an alert when a single GraphQL request fires more than 20 SQL queries. This threshold catches N+1 patterns in development before they reach production. Tools like graphql-query-complexity can enforce this at the validation layer.

Still Not Working?

Verify DataLoader is actually batching. Add logging to the batch function:

const userLoader = new DataLoader(async (ids) => {
  console.log(`Batching ${ids.length} user IDs:`, ids);
  // Should log once per request with all IDs, not once per resolver call
  const users = await db.query('SELECT * FROM users WHERE id IN (?)', [ids]);
  const map = new Map(users.map(u => [u.id, u]));
  return ids.map(id => map.get(id) ?? null);
});

If you see the batch function called once per resolver invocation (not batched), the DataLoader instance is being recreated per resolver call. Move the DataLoader instantiation to the request context.

DataLoader cache prevents seeing updated data — if data changes during a request (after a mutation), the DataLoader cache returns stale data. Clear the cache explicitly:

// After mutation, clear the cached value
userLoader.clear(updatedUserId);

// Or clear everything
userLoader.clearAll();

Schema stitching and federation — if using Apollo Federation, each subgraph has its own resolvers and N+1 can occur at the gateway level. Use @key directives and reference resolvers correctly:

# In the Users subgraph
type User @key(fields: "id") {
  id: ID!
  name: String!
}

# The Posts subgraph references User by key
type Post {
  authorId: ID!
  author: User @provides(fields: "name")
}

For related GraphQL issues, see Fix: GraphQL 400 Bad Request and Fix: Prisma Migration Failed.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles