Skip to content

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

FixDevs · (Updated: )

Part of:  JavaScript & TypeScript Errors

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 x 100 = 800ms)
  SQL: SELECT * FROM comments WHERE post_id = ? (9ms x 100 = 900ms)

Why This Happens

GraphQL resolvers are independent functions. Each field resolver fetches its own data without coordinating with other resolvers at the same level. This independence is a core design principle — it keeps resolvers composable and testable — but it means no resolver knows what other resolvers are about to request.

When a query asks for 100 posts and each post has an author field, the Post.author resolver fires 100 times. Each invocation queries the database individually: SELECT * FROM users WHERE id = ?. The database processes 100 separate queries instead of one SELECT * FROM users WHERE id IN (1, 2, 3, ..., 100). This is the N+1 pattern: 1 query for the list, plus N queries for each item’s relationship.

The problem compounds with depth. If each author also has a posts field, and each of those posts has comments, the query count multiplies: 1 (posts) + N (authors) + NM (authors’ posts) + NM*K (comments). A three-level-deep query on 100 posts can produce tens of thousands of database calls. This is why GraphQL APIs without batching protection can be slower than equivalent REST endpoints, despite fetching less data over the wire.

// 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
  },
};

How Other Tools Solve N+1

The N+1 problem is universal to GraphQL, but different ecosystems and tools address it with fundamentally different strategies. Understanding these approaches helps you pick the right solution for your stack.

DataLoader (JavaScript/TypeScript) is the canonical solution in the Node.js ecosystem. It collects all .load() calls made during a single event-loop tick and batches them into one call to a batch function. This is a request-level cache + batcher — you create a new DataLoader instance per request to avoid stale data across users. DataLoader solves N+1 at the application layer, requiring you to write batch functions for each relationship.

Strawberry (Python) provides a DataLoader-inspired API using asyncio. You define a loader function decorated with @strawberry.dataloader, and Strawberry batches calls across resolvers within the same async context. The batching mechanism relies on Python’s event loop rather than Node.js’s microtask queue, but the principle is identical.

graphene-django (Python) takes a different approach with DjangoListField and query optimization. Instead of DataLoader, it uses Django’s select_related() and prefetch_related() ORM methods to eagerly load relationships in the parent query. This pushes the N+1 fix into the ORM layer rather than the GraphQL layer. The downside is that you must predict which relationships will be queried, or use libraries like graphene-django-optimizer to analyze the query and add the right prefetches automatically.

join-monster rewrites the GraphQL query into a single SQL JOIN statement. Instead of resolving each field independently, it compiles the entire GraphQL query AST into one SQL query with the necessary JOINs. This eliminates N+1 entirely at the database layer but only works with SQL databases and requires schema annotations to map GraphQL types to SQL tables. It trades resolver simplicity for query compilation complexity.

Hasura and PostGraphile are GraphQL engines that generate resolvers from the database schema directly. Both use query planning to produce efficient SQL. Hasura compiles a GraphQL query into a single SQL statement with subqueries or JOINs, and PostGraphile uses look-ahead to batch related queries. Neither requires manual DataLoader setup because the resolver layer is generated, not hand-written. The tradeoff is less control over resolver logic and a tighter coupling to the database schema.

Apollo Federation introduces the N+1 problem at the gateway level. When a query spans multiple subgraphs, the gateway must call each subgraph’s __resolveReference for every entity. The @key directive and entity batching (_entities query) mitigate this, but each subgraph must implement its own batching. A federated architecture needs DataLoader or equivalent in every subgraph.

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 reduced to 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’s findMany with include:

import { PrismaClient } from '@prisma/client';
import DataLoader from 'dataloader';

const prisma = new PrismaClient();

// Custom DataLoader with Prisma
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")
}

Check batch function return order. DataLoader requires the batch function to return results in the exact same order as the input keys. If you query WHERE id IN (3, 1, 2) and the database returns rows ordered [1, 2, 3], DataLoader assigns the wrong result to each caller. Always build a Map and reorder:

const userLoader = 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);  // Preserves input order
});

Watch for DataLoader with composite keys. If your batch function needs to look up by multiple fields (e.g., orgId + userId), serialize the key to a string and deserialize in the batch function:

const memberLoader = new DataLoader(async (keys) => {
  // keys are strings like "org:1:user:5"
  const parsed = keys.map(k => {
    const [, orgId, , userId] = k.split(':');
    return { orgId: Number(orgId), userId: Number(userId) };
  });
  const members = await db.query(/* ... */);
  // Map back to input key order
  return keys.map(k => memberMap.get(k) ?? null);
});

// Usage
memberLoader.load(`org:${orgId}:user:${userId}`);

Test with { cache: false } to rule out caching issues. If disabling the DataLoader cache fixes your problem, the issue is stale cached data from a mutation earlier in the request, not a batching failure.

For related GraphQL issues, see Fix: GraphQL 400 Bad Request, Fix: Prisma N+1 Query Problem, Fix: Prisma Migration Failed, and Fix: GraphQL Subscription Not Updating.

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