Skip to content
SP StackPractices
intermediate By Mathias Paulenko

GraphQL Batched Resolver Pattern

Resolve nested GraphQL fields in a single batched request to eliminate N+1 queries and reduce database load.

Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.

Overview

When a GraphQL query asks for nested fields across a list, each item triggers a separate database call. Querying 50 posts and their authors produces 50 extra SELECT statements — the classic N+1 problem. Batched resolvers collect all keys from the list, make one batched request, and distribute results back to each item.

The pattern pairs naturally with DataLoader, which handles batching, caching, and deduplication per request.

When to Use

  • Resolvers that fetch related data per parent item (posts → author, orders → product)
  • Lists of items where each item has a nested relationship
  • Any GraphQL field that triggers a database query or API call per parent
  • Gateway or stitching layers that delegate to backend services

When Not to Use

  • Single-item queries where N=1 (no batching benefit)
  • Fields resolved from already-loaded data on the parent object (use direct property access)
  • Real-time subscriptions where batching windows add unacceptable latency

Solution

1. The N+1 Problem

Without batching, each post triggers a separate author lookup:

const resolvers = {
  Post: {
    author: (post, _args, { db }) => {
      // Called once per post — 50 posts = 50 queries
      return db.user.findById(post.authorId);
    },
  },
};

Querying 50 posts generates 50 SQL queries:

SELECT * FROM users WHERE id = 1;
SELECT * FROM users WHERE id = 2;
SELECT * FROM users WHERE id = 1;  -- duplicate
SELECT * FROM users WHERE id = 3;
-- ... 46 more

2. DataLoader: Batch and Cache

DataLoader collects all authorId values within a single tick of the event loop, deduplicates them, and sends one batch request.

import DataLoader from 'dataloader';

async function batchLoadUsers(userIds: readonly string[], { db }) {
  // One query: SELECT * FROM users WHERE id IN (1, 2, 3, ...)
  const users = await db.user.findMany({ where: { id: { in: [...userIds] } } });

  // DataLoader expects results 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`));
}

// Create a new DataLoader per request to avoid cross-request caching
function createLoaders(context) {
  return {
    userLoader: new DataLoader(
      (ids) => batchLoadUsers(ids, context),
      { cacheKeyFn: (key) => key.toString() }
    ),
  };
}

3. Use the Loader in Resolvers

const resolvers = {
  Post: {
    author: (post, _args, { loaders }) => {
      // DataLoader batches all calls within the same tick
      return loaders.userLoader.load(post.authorId);
    },
  },
  Query: {
    posts: async (_parent, _args, { db }) => {
      return db.post.findMany({ take: 50 });
    },
  },
};

Now 50 posts produce one SQL query:

SELECT * FROM users WHERE id IN (1, 2, 3, 4, 5, ...);

4. Per-Request Loader Initialization

Create fresh loaders for each request to prevent leaking cached data between users:

import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';

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

const { url } = await startStandaloneServer(server, {
  context: async ({ req }) => {
    const db = getDatabase();
    const loaders = createLoaders({ db });
    return { db, loaders, user: req.headers.authorization };
  },
  listen: { port: 4000 },
});

5. Batching Across Multiple Fields

DataLoader batches across different resolver fields too. If a query asks for both post.author and comment.author, both resolve through the same userLoader:

const resolvers = {
  Post: {
    author: (post, _args, { loaders }) => loaders.userLoader.load(post.authorId),
  },
  Comment: {
    author: (comment, _args, { loaders }) => loaders.userLoader.load(comment.authorId),
  },
};

A query like { posts { author { name } } comments { author { name } } } still produces one batched call for all author IDs.

Explanation

  • Batching window: DataLoader collects all .load() calls within one tick of the Node.js event loop, then fires the batch function once
  • Deduplication: If two posts have the same authorId, DataLoader requests that user once and returns the cached result for both
  • Per-request cache: The cache lives only for the duration of one GraphQL operation, preventing stale data across requests
  • Order preservation: The batch function must return results in the same order as the input keys — DataLoader matches results to callers by position

Variants

Custom Batch Functions for REST APIs

Batch against a REST endpoint that accepts multiple IDs:

const productLoader = new DataLoader(async (productIds: readonly string[]) => {
  const response = await fetch(
    `https://api.example.com/products?ids=${productIds.join(',')}`
  );
  const products = await response.json();
  const productMap = new Map(products.map((p) => [p.id, p]));
  return productIds.map((id) => productMap.get(id));
});

Batch with Max Batch Size

Limit batch size to avoid oversized SQL IN clauses:

const userLoader = new DataLoader(batchLoadUsers, {
  maxBatchSize: 100,
  cache: true,
});

Batch with Priming

Pre-populate the cache when you already have data:

Query: {
  user: async (_parent, { id }, { db, loaders }) => {
    const user = await db.user.findById(id);
    // Prime the cache so nested resolvers don't re-fetch
    loaders.userLoader.prime(id, user);
    return user;
  },
},

Batch with Custom Cache Key

For composite keys (e.g., tenant + user ID):

const loader = new DataLoader(batchLoad, {
  cacheKeyFn: (key) => `${key.tenantId}:${key.userId}`,
});

Best Practices

  • For a deeper guide, see GraphQL DataLoader Pattern.

  • Create one DataLoader instance per request, never shared across requests

  • Always return results in the same order as the input keys in batch functions

  • Use prime() when you already have data to avoid redundant fetches

  • Set maxBatchSize to keep SQL IN clauses within database limits

  • Handle errors per-key: return an Error object for failed items, not a thrown exception

  • Monitor batch sizes in production to catch unexpected query patterns

Common Mistakes

  • Sharing DataLoader across requests: Causes data leaks between users and stale cache hits
  • Returning results out of order: DataLoader matches by position, so misaligned results silently return wrong data
  • Throwing in batch function: One failed key rejects the entire batch. Return new Error() per item instead
  • Not using cacheKeyFn for non-string keys: Objects as keys cause cache misses because {} !== {} by reference
  • Forgetting to clear cache on mutations: After updating a user, call loader.clear(id) to prevent stale reads

FAQ

Does DataLoader work with subscriptions?

Yes, but the batching window may behave differently. For subscriptions, consider disabling batching or using a shorter batch interval.

How is this different from the DataLoader pattern?

The batched resolver pattern is the broader concept — DataLoader is one implementation. You can also batch with custom logic, Redis pipelines, or GraphQL.js’s info field collection.

Can I batch mutations?

Not directly. Mutations execute sequentially by design. Use a single mutation with list input instead of multiple mutation calls.

What about batching in federation?

Apollo Federation’s query planner automatically batches field resolution across services. If you’re using federation, you may not need manual DataLoader for cross-service fields.

Is this pattern suitable for small projects?

For small projects with few components, this pattern may add unnecessary complexity. Start simple and introduce the pattern when you feel the pain it solves.

How does this pattern compare to alternatives?

Each pattern makes different trade-offs. Review the variants table above and consider your specific constraints: team size, performance requirements, and future scaling plans.

Can I partially apply this pattern?

Yes. Many teams adopt patterns incrementally. Start with the core idea and add sophistication as needed. The pattern is a guide, not a strict blueprint.

Advanced Topics

Scenario: Batched Resolver for N+1 in GraphQL

// DataLoader: batch requests to avoid N+1
import DataLoader from "dataloader";

// Resolver without batching: N+1 queries
// query { users { orders { id } } } -> 1 users query + N orders queries

// Resolver with DataLoader: 1 batch query
const orderLoader = new DataLoader<string, Order[]>(async (userIds) => {
  // 1 query for all users
  const res = await db.query(
    "SELECT * FROM orders WHERE user_id = ANY($1)",
    [userIds]
  );
  // Group by user_id
  const ordersByUser = new Map<string, Order[]>();
  for (const order of res.rows) {
    const list = ordersByUser.get(order.user_id) || [];
    list.push(order);
    ordersByUser.set(order.user_id, list);
  }
  // Return in same order as userIds
  return userIds.map(id => ordersByUser.get(id) || []);
});

// GraphQL resolver
const resolvers = {
  User: {
    orders: (parent: User) => orderLoader.load(parent.id),
  },
  Query: {
    users: async () => {
      const res = await db.query("SELECT * FROM users LIMIT 100");
      return res.rows;
    },
  },
};

// Before: 100 users -> 100 orders queries (N+1)
// After: 100 users -> 1 orders query (batched)
// DataLoader groups all .load() calls in one tick

Lessons:

  • DataLoader groups .load() calls in the same event loop tick
  • 1 batch query instead of N individual queries
  • DataLoader caches per request: do not share across requests
  • The result order must match the key order
  • In Apollo Server, create DataLoader per request in context
  • Use for any 1:N or N:1 relationship in GraphQL

### How do I configure DataLoader in Apollo Server?

Create a DataLoader instance per request in the context function. Do not share DataLoader across requests: one request cache can pollute another. In context: () => ({ orderLoader: new DataLoader(batchFn) }). The DataLoader is created per request and discarded when done. For cross-request caching, use Redis with cacheKeyFn. For manual batching without DataLoader: group ids in a Set and make a single query with IN clause.