StackPractices
intermediate By Mathias Paulenko

Batch and Cache Database Queries with GraphQL DataLoader

Use DataLoader to coalesce individual load requests into batched database calls, solving the N+1 query problem in GraphQL resolvers

When a GraphQL query resolves nested relationships — like fetching the author of each post in a list — a naive resolver issues one database query per item. This is the N+1 problem: one query for the list, plus N queries for the related data. DataLoader solves this by collecting individual load requests within a single event loop tick and dispatching them as a single batched query.

When to Use This

  • Resolvers that fetch related data by foreign key (post.author, user.posts, order.items)
  • Any GraphQL schema with nested type relationships
  • APIs where N+1 queries cause latency or database connection exhaustion

Prerequisites

  • Node.js 18+ with a GraphQL server (Apollo Server, GraphQL Yoga)
  • A database client that supports WHERE id IN (...) queries

Solution

1. Install DataLoader

npm install dataloader

2. Create a Batch Loader Function

// loaders.ts
import DataLoader from 'dataloader';

type User = { id: string; name: string; email: string };
type Post = { id: string; title: string; authorId: string };

export function createUserLoader(db: { users: { findMany: (opts: any) => Promise<User[]> } }) {
  return new DataLoader<string, User>(async (userIds: readonly string[]) => {
    const users = await db.users.findMany({
      where: { id: { in: [...userIds] } },
    });

    const userMap = new Map(users.map((u) => [u.id, u]));

    return userIds.map((id) => userMap.get(id) ?? new Error(`User ${id} not found`));
  });
}

export function createPostLoader(db: { posts: { findMany: (opts: any) => Promise<Post[]> } }) {
  return new DataLoader<string, Post[]>(async (authorIds: readonly string[]) => {
    const posts = await db.posts.findMany({
      where: { authorId: { in: [...authorIds] } },
    });

    return authorIds.map((authorId) =>
      posts.filter((p) => p.authorId === authorId)
    );
  });
}

3. Inject Loaders Per Request

Create a fresh DataLoader instance per request so the cache only lives for the duration of that request:

// context.ts
import { createUserLoader, createPostLoader } from './loaders';

export type Context = {
  db: DbConnection;
  user: User | null;
  loaders: {
    user: DataLoader<string, User>;
    postsByAuthor: DataLoader<string, Post[]>;
  };
};

export function createContext(db: DbConnection): Context {
  return {
    db,
    user: null,
    loaders: {
      user: createUserLoader(db),
      postsByAuthor: createPostLoader(db),
    },
  };
}

4. Use Loaders in Resolvers

// resolvers.ts
export const resolvers = {
  Query: {
    posts: (_: unknown, __: unknown, ctx: Context) =>
      ctx.db.posts.findMany({ take: 20 }),
  },

  Post: {
    author: (post: Post, _: unknown, ctx: Context) =>
      ctx.loaders.user.load(post.authorId),
  },

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

5. Wire Up in Apollo Server

// server.ts
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import { createContext } from './context';
import { db } from './db';
import { typeDefs } from './schema';
import { resolvers } from './resolvers';

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

const { url } = await startStandaloneServer(server, {
  context: async ({ req }) => {
    const ctx = createContext(db);
    const token = req.headers.authorization?.replace('Bearer ', '');
    ctx.user = token ? await verifyToken(token) : null;
    return ctx;
  },
});

console.log(`Server ready at ${url}`);

How It Works

  1. Batching: DataLoader collects all .load(id) calls within the same tick. When process.nextTick fires, it dispatches them as a single batch function call with all IDs.
  2. Caching: After the batch function returns, results are cached by key. Subsequent .load(id) calls for the same key return the cached value without hitting the database.
  3. Per-request isolation: A new DataLoader instance is created in the context factory for each request. This prevents cross-request cache leaks.
  4. Error handling: If the batch function returns an Error for a specific key, that error is thrown when .load(id) is called for that key — other keys in the batch are unaffected.

Variants

Redis-Backed DataLoader

For shared caching across requests, wrap the batch function with a Redis lookup:

export function createRedisUserLoader(redis: RedisClient, db: DbConnection) {
  return new DataLoader<string, User>(async (ids: readonly string[]) => {
    const cached = await redis.mget(ids.map((id) => `user:${id}`));
    const uncachedIds = ids.filter((_, i) => !cached[i]);

    const fresh = await db.users.findMany({ where: { id: { in: uncachedIds } } });
    await Promise.all(fresh.map((u) => redis.set(`user:${u.id}`, JSON.stringify(u), 'EX', 300)));

    const userMap = new Map(fresh.map((u) => [u.id, u]));
    return ids.map((id, i) => cached[i] ? JSON.parse(cached[i]!) : userMap.get(id)!);
  });
}

Custom Batch Schedule

For high-throughput scenarios, use a custom batch scheduler to control when batches dispatch:

const loader = new DataLoader(batchFn, {
  batchScheduleFn: (callback) => setTimeout(callback, 10),
});

Best Practices

  • For a deeper guide, see Detect and Fix N+1 Queries in GraphQL Resolvers.

  • Create loaders per request — never share DataLoader instances across requests; the cache leaks data between users

  • Sort batch results to match input order — DataLoader expects the return array to align with the input key order

  • Return errors per-key — throw new Error() for missing keys instead of rejecting the whole batch

  • Disable batching for single-item loads — set { batch: false } when you know a loader will only ever load one key

Common Mistakes

  • Sharing a DataLoader across requests — causes stale data and cross-user cache contamination
  • Not returning results in input order — DataLoader maps results by position, not by key; misaligned arrays produce wrong data
  • Using .load() in a loop without awaiting — DataLoader batches automatically, but you must still await each .load() call
  • Caching across requests with the default cache — use { cache: false } or a request-scoped cache if you need cross-request caching

Error Handling and Recovery

  • DataLoader error propagation: when a batch function throws, DataLoader rejects all keys in the batch. Wrap batch functions in try/catch. Return individual errors per key using Error instances. Use . clear(key) to remove failed entries from cache.
  • Partial batch failures: if some items in a batch succeed and others fail, return results for successful items and Error objects for failed ones. DataLoader supports returning a mix of values and errors in the batch array. The caller receives individual errors via .
  • Timeout handling: set a timeout on batch functions (e. g. , 5 seconds). If the timeout fires, reject all pending loads. race with a timeout promise.
  • Database connection failures: if the database is unavailable, the batch function should reject with a descriptive error. Fall back to cached data if available.
  • Cache invalidation errors: if prime() is called with stale data, subsequent loads return incorrect results. Validate primed data before caching. Use . clearAll() on schema changes or deployments.
  • Memory pressure from cache: DataLoader caches by reference. Large cached objects can cause memory pressure in long-running processes. Set maxAgeMs or use a custom cache Map with LRU eviction.

Performance Optimization Tips

  • Batch size tuning: optimal batch size depends on database and query complexity. Start with 100-500 items per batch. Larger batches reduce round trips but increase per-query cost.
  • Cache strategy selection: default cache is per-request (Map). For read-heavy workloads, use a shared LRU cache across requests. Set cacheKeyFn for complex keys to avoid object reference issues.
  • Distributed batching: in serverless environments, each instance has its own DataLoader. Publish batch results to a Redis channel. Other instances consume and prime their local DataLoader.
  • Schedule timing: the default maxBatchSize and scheduling may not be optimal. For high-throughput scenarios, dispatch batches every 1ms instead of waiting for the next tick.
  • Query optimization: ensure database queries use appropriate indexes for batched lookups.
  • Memory management: use maxBatchSize to limit batch memory. Clear DataLoader instances after each request in web servers.

Security Considerations

  • Authorization in batch functions: check permissions for each key in the batch. An attacker may request keys they are not authorized to access. Return null or Error for unauthorized keys. Do not leak existence of unauthorized resources.
  • Batch injection attacks: validate all keys before passing to the database. An attacker may craft keys to inject SQL or cause unexpected behavior. Sanitize keys with the same validation as direct queries.
  • Cache poisoning: if an attacker can prime the cache with incorrect data, subsequent loads return poisoned results. Validate primed data server-side. Do not allow client-controlled cache priming.
  • Rate limiting batch loads: a malicious client may call . load() thousands of times per request. Limit the number of . load() calls per request (e. g. , 100). Return an error if the limit is exceeded.
  • Information disclosure: batch functions may return different error messages for existing vs non-existing keys. This can leak information about resource existence. Log detailed errors server-side only.
  • DataLoader in federated schemas: in a federated gateway, each subgraph has its own DataLoader. A subgraph may receive requests from the gateway without user context. Pass user context through the federation query plan.

Testing and Quality Assurance

  • Unit testing batch functions: test batch functions in isolation with mocked database calls. Verify that the function returns results in the same order as input keys. Test error handling for each key independently.
  • Integration testing with DataLoader: test DataLoader within a GraphQL resolver context. Verify that N+1 queries are eliminated by counting database calls. Assert that a query requesting 100 items results in exactly 1 database call.
  • Cache behavior testing: test that . load() returns cached results on second call. clear(key) removes only the specified key. clearAll() removes all keys. prime(key, value) caches without fetching.
  • Load testing: use Artillery or k6 to send 1000+ concurrent GraphQL queries. Verify that DataLoader reduces database queries by 80-95% compared to naive resolvers.
  • Snapshot testing: snapshot the GraphQL response for representative queries. Detects unintended changes in resolver behavior.
  • Error scenario testing: test batch function with database timeout, connection failure, and partial failures. Verify that errors are properly propagated to individual . load() calls.

Deployment and CI/CD

  • DataLoader lifecycle in web servers: create a new DataLoader instance per request. Dispose after the response is sent. Never share DataLoader instances across requests in long-running servers.
  • Monitoring DataLoader metrics: track batch count, batch size, cache hit rate, and error rate. Export metrics via Prometheus. Set up Grafana dashboards. Alert on error rate > 1%.
  • Feature flags for batching: deploy DataLoader behind a feature flag. Roll out to a percentage of traffic first. If metrics improve, increase rollout. If regressions occur, roll back immediately.

Cost Optimization

  • Database connection pooling: DataLoader reduces database queries but each batch still needs a connection. Set pool size based on peak concurrent batches.
  • Caching to reduce database load: use DataLoader cache with Redis for cross-request caching. Cache common batch results for 5-15 minutes. Invalidate on mutations. Reduces database load by 50-90% for read-heavy workloads.
  • Serverless cost impact: in serverless environments, each invocation pays for execution time. DataLoader reduces database round trips, reducing execution time and cost.

Common Pitfalls and Anti-Patterns

  • Sharing DataLoader across requests: never share a DataLoader instance across HTTP requests in a web server. Each request should get a fresh instance. Sharing leads to cache leakage between users and potential authorization bypass.
  • Not handling null keys: DataLoader batch functions receive null keys when resolvers return null. Return null for null input keys. Do not pass null to database queries.

Troubleshooting

  • Query returns null unexpectedly: verify resolvers, data loaders, and authorization.
  • N+1 query performance issue: use DataLoader or equivalent batching. Inspect resolver execution traces.
  • Introspection disabled in production breaks tools: enable it only in development, or use schema artifacts in CI.
  • Mutation input rejected: confirm input validation, custom scalars, and whether variables are passed as the right type.
  • Subscription stops receiving events: check the pub/sub backend, event filtering, and that the resolver is emitting events.

Further Reading

  • Official documentation: check the current reference for the framework or tool used.
  • Related guides: explore the graphql and dataloader guides for deeper coverage.
  • Complementary patterns: review design patterns applicable to your technology stack.
  • Public postmortems: study real incidents from teams that faced similar production issues.

Production Notes

  • Deploy gradually using canary or blue-green to catch regressions early.
  • Configure alerts for error rate, p99 latency, and failure rate before enabling in production.
  • Document the rollback in the runbook; test the procedure in staging at least once per quarter.
  • Review structured logs with correlation IDs to trace requests end-to-end during incidents.

Key Takeaways

  • Apply batch and cache database queries with graphql dataloader when you need a practical solution for your use case.
  • Monitor performance after implementation; measure latency, errors, and resource usage before and after.
  • Check the Troubleshooting section for common failures; most have documented root causes with fixes.
  • Keep dependencies updated and run tests in CI to prevent production regressions.

Common Production Pitfalls

  • Copying the example without adapting it to real data volumes and failure modes.
  • Skipping load and error-injection tests before the first production deployment.
  • Hard-coding values that should be configurable per environment.
  • Forgetting to add logging and monitoring at each step.
  • Deploying without a rollback plan or a tested backup strategy.
  • Assuming the minimal example will scale without adding caching or batching.
  • Not documenting the version and configuration used in production.
  • Letting the recipe sit unchanged when dependencies or scale evolve.

Frequently Asked Questions

Does DataLoader cache across requests?

No. The default cache is per-instance. Since you create a new instance per request, the cache is request-scoped. For cross-request caching, use Redis or another shared store.

Can I use DataLoader with non-GraphQL code?

Yes. DataLoader works anywhere you need to batch individual async loads. It is not tied to GraphQL.

What happens if a batch function throws?

The error propagates to all pending .load() calls for that batch. Handle errors per-key by returning Error objects in the result array instead.

Should I use DataLoader for one-to-many relationships?

Yes. For one-to-many (e.g., user.posts), the batch function groups results by foreign key and returns arrays per key.