Skip to content

GraphQL DataLoader — Solving the N+1 Problem with Batching

DodaTech Updated 2026-06-28 6 min read

In this tutorial, you will learn about GraphQL DataLoader. We cover key concepts, practical examples, and best practices to help you master this topic.

DataLoader is a utility for batching and caching database queries in GraphQL resolvers, solving the N+1 Problem by coalescing individual loads into batch requests.

What You'll Learn

You will learn the N+1 problem, how DataLoader batches loads from multiple resolvers, how to implement loaders for relationships, caching strategies, and production patterns.

Why DataLoader Matters

Without DataLoader, fetching 100 devices and their threats makes 101 database queries — one for devices, then 100 individual queries for threats. With DataLoader, those 100 threat queries become one batch query. DodaTech's Durga Antivirus Pro serves thousands of dashboard queries per second — DataLoader reduces database load by 10-50x on nested queries, keeping response times under 50ms even for deeply nested dashboard views.

sequenceDiagram
    participant R1 as Device.threats
(device 1) participant R2 as Device.threats
(device 2) participant R3 as Device.threats
(device 3) participant DL as DataLoader participant DB as Database R1->>DL: load("dev-001") R2->>DL: load("dev-002") R3->>DL: load("dev-003") Note over DL: Coalesces in same tick DL->>DB: SELECT * FROM threats
WHERE device_id IN (dev-001, dev-002, dev-003) DB-->>DL: Batched results DL-->>R1: threats for dev-001 DL-->>R2: threats for dev-002 DL-->>R3: threats for dev-003
â„šī¸ Info

Prerequisites: GraphQL nested resolvers. Database query knowledge.

The N+1 Problem

const resolvers = {
  Query: {
    devices: () => db.findAllDevices(),  // 1 query
  },
  Device: {
    threats: (parent) => db.findThreatsByDeviceId(parent.id),  // N queries!
  },
};

Query: { devices { name threats { name } } } with 100 devices = 1 + 100 = 101 queries. This is the N+1 problem.

Basic DataLoader Setup

const DataLoader = require('dataloader');

// Batch function — receives array of keys, returns array of values
async function batchThreatsByDeviceIds(deviceIds) {
  const threats = await db.threats.findByDeviceIds(deviceIds);
  // Must return results in the SAME ORDER as deviceIds
  return deviceIds.map(id => threats.filter(t => t.deviceId === id));
}

// Create loader in context
const context = {
  threatLoader: new DataLoader(batchThreatsByDeviceIds),
};

// In resolver
const resolvers = {
  Device: {
    threats: (parent, args, context) => {
      return context.threatLoader.load(parent.id);
    },
  },
};

Now 100 devices = 1 query for devices + 1 batch query for threats = 2 queries total.

Multiple DataLoaders

// One loader per entity relationship
function createLoaders() {
  return {
    userLoader: new DataLoader(ids => 
      db.users.findByIds(ids).then(rows => ids.map(id => rows.find(r => r.id === id)))
    ),
    deviceLoader: new DataLoader(ids => 
      db.devices.findByIds(ids).then(rows => ids.map(id => rows.find(r => r.id === id)))
    ),
    threatLoader: new DataLoader(ids => 
      db.threats.findByDeviceIds(ids).then(rows => ids.map(id => rows.filter(r => r.deviceId === id)))
    ),
    scanLoader: new DataLoader(ids => 
      db.scans.findByDeviceIds(ids).then(rows => ids.map(id => rows.filter(r => r.deviceId === id)))
    ),
  };
}

// Create fresh loaders per request in context
const server = new ApolloServer({
  typeDefs,
  resolvers,
  context: () => ({
    loaders: createLoaders(),
    db,
  }),
});

DataLoader with Caching

// DataLoader has built-in per-request caching
const loader = new DataLoader(batchFn, {
  cache: true,  // default: true — caches within the same request
  maxBatchSize: 100,  // max keys per batch
  batchScheduleFn: callback => setTimeout(callback, 0),  // next tick
});

loader.load('key1');  // Queued for batch
loader.load('key1');  // Returns cached — no duplicate query!
loader.load('key2');  // Same batch as key1
// Example: loading the same user multiple times
const resolvers = {
  Threat: {
    reportedBy: (parent, args, context) => {
      // If 20 threats were reported by the same user,
      // DataLoader still only makes ONE query
      return context.loaders.userLoader.load(parent.reportedById);
    },
  },
};

Type-Safe DataLoader with TypeScript

import DataLoader from 'dataloader';

interface User { id: string; name: string; email: string; }
interface Device { id: string; name: string; userId: string; }

const userLoader = new DataLoader<string, User | null>(
  async (ids: readonly string[]) => {
    const users = await db.users.findByIds([...ids]);
    return ids.map(id => users.find(u => u.id === id) || null);
  }
);

const deviceLoader = new DataLoader<string, Device[]>(
  async (ids: readonly string[]) => {
    const devices = await db.devices.findByUserIds([...ids]);
    return ids.map(id => devices.filter(d => d.userId === id));
  }
);

Common Mistakes

1. Not Preserving Key Order

The batch function MUST return results in the same order as the input keys. Mismatched ordering causes data corruption that is hard to debug.

2. Creating Loaders Outside Request Scope

DataLoader caching only works within a single request. If you create loaders globally, cached data leaks between requests. Create new loaders per request in the context Factory.

3. Ignoring Error Handling in Batch Functions

If one key fails, the entire batch fails. Use try/catch in batch functions and return per-key errors when possible.

4. Missing Keys in Results

If a key has no matching result, return null for that position. Returning fewer results than keys causes index mismatches.

5. Batching Too Many Keys

Without maxBatchSize, a single batch could load 10,000 keys, overwhelming the database. Set maxBatchSize: 100 or based on your database limits.

Practice Questions

  1. What problem does DataLoader solve?
  2. How does DataLoader batch multiple loads?
  3. What is the most important rule for batch function results?
  4. Why should DataLoaders be created per request?
  5. What does the maxBatchSize option control?

Answers:

  1. The N+1 problem — when N parent items trigger N separate child queries, resulting in N+1 total database queries. DataLoader coalesces them into batch queries.
  2. Multiple load() calls within the same event-loop tick are collected. On the next tick, the batch function receives all queued keys at once.
  3. Results must be returned in the exact same order as the input keys. If keys are [A, B, C], results must be [resultForA, resultForB, resultForC].
  4. DataLoader caches loaded values within its instance. If shared across requests, user A could see user B's cached data. Per-request loaders isolate caching.
  5. maxBatchSize limits the number of keys in a single batch call. If 1000 keys are queued with maxBatchSize: 100, DataLoader makes 10 batch calls of 100 each.

Challenge: Implement DataLoaders for DodaTech's complete GraphQL schema. Create loaders for User, Device, Threat, Scan, and Alert. Each loader should handle null results (key not found), maintain order, and set appropriate maxBatchSize. Implement in the context factory.

FAQ

Is DataLoader specific to GraphQL?

No — DataLoader is a generic utility for batching and caching. It was created by Lee Byron (GraphQL spec author) for GraphQL resolvers, but can be used anywhere you need request-level batching.

Does DataLoader work with SQL databases?

Yes — the batch function can call any data source. For SQL, use WHERE id IN (...) with the batched keys. For REST, make one batch API call.

Can DataLoader handle one-to-many relationships?

Yes — for one-to-many (device → threats), the batch function returns an array per key: deviceIds.map(id => threats.filter(t => t.deviceId === id)).

What happens if a key has no results?

Return null (for one-to-one) or empty array (for one-to-many) at that key's position. Never return fewer items than the number of keys.

Does DataLoader support mutations?

Not directly — DataLoader is designed for reads. For mutations, always invalidate the relevant cache entries using loader.clear(key) or loader.clearAll().

Mini Project

Implement a complete DataLoader system for DodaTech's API. Create loaders for all entity relationships: user → devices, device → threats, threat → alerts, user → scanHistory. Use per-request loader instances, implement the batch functions with SQL IN queries, add error handling, and test with 100+ nested items to verify batching reduces query count from N+1 to 2.

What's Next

Topic Description
Batching Strategies Additional batching techniques
Error Handling Error patterns in GraphQL
Performance Optimization Query cost, caching, and optimization
âŦ… Nested Resolvers
➡ Batching Strategies

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro