What the N+1 problem looks like in GraphQL resolvers

The N+1 problem appears when a list field returns N parent objects and each child field resolves on its own. GraphQL executes resolvers per field, per object, so a resolver that fetches related data runs once for every item in the list. You get one call for the list plus N calls for the children.

Take a schema where Query.posts returns [Post] and each Post has an author: User field. The posts resolver runs one SQL query to fetch the posts. Then the Post.author resolver runs once per post. Each run reads post.authorId and issues SELECT * FROM users WHERE id = ?. Nothing in this code is wrong. Every resolver is correct in isolation. The cost comes from the execution model, which has no built-in way to combine sibling resolver calls into one query.

Query counts as the list grows (illustrative estimate)

Posts returned Naive resolvers (1 + N) Batched author lookup
10 11 queries 2 queries
100 101 queries 2 queries
1,000 1,001 queries 2 queries

The batched column assumes all author IDs are collected into a single WHERE id IN (...) query.

Each extra query adds a network round trip, connection-pool pressure and per-query overhead. Latency grows with page size and nested lists multiply the effect. This is the main reason teams adopt DataLoader: it fixes the problem without restructuring the resolvers. It matters most to backend and platform engineers running GraphQL servers over SQL databases or internal HTTP and RPC services, where every call has a real cost.

import { Pool } from 'pg';

const pool = new Pool({ connectionString: 'postgres://app:example-password@localhost:5432/blog' });

// Count every SQL call so the 1 + N pattern is visible in logs.
let queryCount = 0;
async function sql<T>(text: string, params: unknown[] = []): Promise<T[]> {
  queryCount++;
  const result = await pool.query(text, params);
  return result.rows as T[];
}

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

export const resolvers = {
  Query: {
    // 1 query: fetch the page of posts.
    posts: (_: unknown, args: { limit: number }) =>
      sql<Post>('SELECT id, title, author_id AS "authorId" FROM posts ORDER BY id LIMIT $1', [args.limit]),
  },
  Post: {
    // Runs once PER post: N more queries, one per parent object.
    // Correct on its own, but sibling calls are never combined.
    author: async (post: Post) => {
      const rows = await sql<User>('SELECT id, name FROM users WHERE id = $1', [post.authorId]);
      return rows[0] ?? null;
    },
  },
};

// Example: { posts(limit: 100) { title author { name } } }
// queryCount ends at 101: 1 for posts + 100 for authors.
export const getQueryCount = () => queryCount;

Naive posts → author resolvers using node-postgres, where the Post.author resolver issues its own SELECT for every post and turns one list query into 1 + N queries.

How DataLoader batches load calls within one event-loop tick

Each call to loader.load(key) does not hit the database. It records the key in a queue and returns a promise. The loader schedules a dispatch to run after the current synchronous work and its pending promise callbacks finish. When the dispatch runs, it passes every queued key to your batch function in one call. That function receives an array of keys and must return a promise of an array of values, one per key.

 post[0].author ─┐
 post[1].author ─┤ load(7), load(3), load(7), load(12) ...
 post[2].author ─┼──────────────▶ [ queue: 7, 3, 12 ]
      ...        │                        │ end of tick
 post[N].author ─┘                        ▼
                               batchFn([7, 3, 12])
                                          │
                          SELECT * FROM authors WHERE id IN (7, 3, 12)
                                          │
        ◀──── resolve each promise with its own row ────┘

This works because GraphQL resolves sibling fields together. All author resolvers for a list of posts are invoked before any of them settles, so their load calls land in the same queue. Batching happens once per level of nesting, not once per operation. A query for posts → author → company issues one batch for authors and then, after those resolve, another for companies. The query count tracks depth rather than row count.

Batching breaks when anything forces the dispatch to run before the remaining keys arrive. Two patterns are common:

  • Awaiting other work between load calls. If a resolver awaits a permission check or a config fetch before calling load, its key may miss the batch that already went out.
  • Awaiting loads one at a time in a loop. Each await waits for one batch of one key before the next load call runs, which recreates the N+1 pattern. Collect the promises first, then await them together, or use loadMany.
import DataLoader from 'dataloader';

// Batch function: receives every key queued during one tick and must
// return a promise of values in the same order as the keys.
async function batchAuthors(ids: readonly number[]) {
  const rows = await db.query(
    'SELECT * FROM authors WHERE id = ANY($1)',
    [ids]
  );
  const byId = new Map(rows.map((r) => [r.id, r]));
  // One result per key, in key order; missing rows become Errors.
  return ids.map((id) => byId.get(id) ?? new Error(`author ${id} not found`));
}

// Create one loader per request so the cache never leaks across users.
export function createLoaders() {
  return { author: new DataLoader(batchAuthors) };
}

export const resolvers = {
  Post: {
    // Good: called for every post in the list before any settles,
    // so all keys land in one batch -> one IN query.
    author: (post, _args, ctx) => ctx.loaders.author.load(post.authorId),
  },
  Book: {
    // Bad: each await waits for a one-key batch before the next load runs.
    async coAuthorsSlow(book, _args, ctx) {
      const out = [];
      for (const id of book.coAuthorIds) {
        out.push(await ctx.loaders.author.load(id));
      }
      return out;
    },
    // Good: queue every key in the same tick, then await them together.
    coAuthors: (book, _args, ctx) =>
      Promise.all(book.coAuthorIds.map((id) => ctx.loaders.author.load(id))),
  },
};

A per-request DataLoader whose batch function turns all keys queued in one tick into a single query, plus a sequential-await loop that defeats batching next to the Promise.all version that keeps it.

Implementing an order-safe batch function and per-request loaders

The batch function contract

DataLoader passes your batch function an array of keys and expects a promise for an array of values. That array must have the same length as the keys, and each value must sit at the same index as the key it belongs to. The database does not respect that order. A query such as SELECT ... WHERE id IN (...) returns rows in whatever order the planner chooses, omits ids that don't exist, and may return duplicates if you join carelessly.

So run one IN query, build a map from id to row, then map over the original keys. For each key, return the row if it exists, or null (or an Error instance) if it doesn't. Never filter the result. If you return rows in query order, or return a shorter array, values shift by index and DataLoader resolves each parent with someone else's child. This produces wrong data that looks plausible, so nothing fails loudly. DataLoader does reject a batch whose result length doesn't match, but a correct length in the wrong order passes that check. Normalize key types too. A string "42" will not match a numeric 42 from the database.

Return Error instances per key when only some keys fail. If the batch promise itself rejects, every key in that batch rejects, including keys that would have succeeded.

One loader set per request

Create the loaders inside the GraphQL context factory so each request gets fresh instances. A module-level loader keeps its cache for the life of the process. That serves stale data after writes, and it can return rows fetched under one user's permissions to a different user.

import DataLoader from 'dataloader';

type User = { id: number; name: string };
// Placeholder DB client: runs parameterized SQL and resolves to rows.
declare const db: { query<T>(sql: string, params: unknown[]): Promise<T[]> };

async function batchUsers(keys: readonly number[]): Promise<(User | Error)[]> {
  // One IN query for the whole batch. If this throws, every key in the batch rejects.
  const placeholders = keys.map((_, i) => `$${i + 1}`).join(', ');
  const rows = await db.query<User>(
    `SELECT id, name FROM users WHERE id IN (${placeholders})`,
    [...keys],
  );

  // Rows come back in planner order with missing ids omitted, so index by id.
  // Normalize the key type so "42" from a driver still matches 42 from GraphQL.
  const byId = new Map<number, User>();
  for (const row of rows) byId.set(Number(row.id), row);

  // Map over the ORIGINAL keys: same length, same order. Never filter.
  // A missing row becomes a per-key Error, so only that one field fails.
  return keys.map((key) => byId.get(key) ?? new Error(`User ${key} not found`));
}

// BUG if done at module level: the cache lives for the whole process,
// serves stale rows after writes, and leaks one viewer's results to another.
// const userLoader = new DataLoader(batchUsers);

// Context factory: called once per request, so each request gets fresh loaders
// whose cache only ever holds data fetched for this viewer.
export function createContext(viewer: { id: number }) {
  return {
    viewer,
    loaders: {
      user: new DataLoader<number, User>(batchUsers),
    },
  };
}

An order-safe batch function that runs one IN query, indexes rows by normalized id, returns one value or per-key Error for each input key in input order, and is wired into loaders created fresh in the per-request context factory.

Using the per-request cache: deduplication, clear, prime, and custom keys

Within one request, load(key) is memoized: the first call stores a promise in the loader's cache, and every later call with the same key returns that same promise. If fifty comments resolve the same author, the batch function receives that author ID once. Because the cache holds promises, deduplication works even before the first query returns.

The cache does not know about your writes. If a mutation updates a user and the same operation then selects that user, call clear(userId) after the write so the next load fetches the new row instead of the pre-mutation one. Use clearAll() only when a write touches rows you can't enumerate by key.

The reverse case is prime(key, value). When a parent resolver already fetched full rows, for example a users list query, prime each row into the user loader. Child resolvers that call load(id) then hit the cache instead of issuing a second query. prime does not overwrite an existing entry, so to replace a value you must call clear first and then prime.

Keys are compared by identity. Two { tenantId, id } objects with equal fields are still different keys, so they won't deduplicate. Supply a cacheKeyFn that returns a stable string, such as tenant and ID joined with a separator. The batch function still receives the original key objects.

For one-to-many relations like posts by author ID, the batch function returns one array per key, in key order. Query all matching posts, group them by authorId, and return an empty array for authors with no rows. Returning undefined or skipping the key would misalign results or break a non-null list field.

import DataLoader from "dataloader";
import { db } from "./db"; // assumed helper: returns rows with camelCase fields

type User = { id: string; tenantId: string; name: string };
type Post = { id: string; authorId: string; title: string };
type TenantKey = { tenantId: string; id: string };
const tk = (k: { tenantId: string; id: string }) => `${k.tenantId}:${k.id}`;

// Create a new set of loaders for each request so cached values are never shared between requests.
export function createLoaders() {
  const user = new DataLoader<string, User | null>(async (ids) => {
    const rows: User[] = await db.query("SELECT * FROM users WHERE id = ANY($1)", [ids]);
    const byId = new Map(rows.map((u) => [u.id, u]));
    return ids.map((id) => byId.get(id) ?? null); // same length and order as ids
  });

  // One-to-many relation: return one array per author, and [] for authors with no posts.
  const postsByAuthor = new DataLoader<string, Post[]>(async (authorIds) => {
    const rows: Post[] = await db.query("SELECT * FROM posts WHERE author_id = ANY($1)", [authorIds]);
    const grouped = new Map<string, Post[]>();
    for (const p of rows) grouped.set(p.authorId, [...(grouped.get(p.authorId) ?? []), p]);
    return authorIds.map((id) => grouped.get(id) ?? []);
  });

  // Object keys: cacheKeyFn turns each key into a string, so keys with equal fields deduplicate.
  // The batch function still receives the original key objects.
  const userByTenant = new DataLoader<TenantKey, User | null, string>(async (keys) => {
    const rows: User[] = await db.usersByTenantKeys(keys);
    const byKey = new Map(rows.map((u) => [tk(u), u]));
    return keys.map((k) => byKey.get(tk(k)) ?? null);
  }, { cacheKeyFn: tk });

  return { user, postsByAuthor, userByTenant };
}

export const resolvers = {
  Query: {
    users: async (_: unknown, __: unknown, { loaders }: any) => {
      const rows: User[] = await db.query("SELECT * FROM users LIMIT 50");
      rows.forEach((u) => loaders.user.prime(u.id, u)); // child resolvers that call load(id) now read from the cache
      return rows;
    },
  },
  Mutation: {
    renameUser: async (_: unknown, { id, name }: any, { loaders }: any) => {
      await db.query("UPDATE users SET name = $1 WHERE id = $2", [name, id]);
      loaders.user.clear(id); // remove the cached pre-mutation row
      return loaders.user.load(id); // fetches the updated row
    },
  },
};

Per-request loaders that batch and deduplicate keys, group posts by author with an empty array for authors without posts, use cacheKeyFn to deduplicate composite tenant/ID keys, and call prime after a list query and clear after a mutation.

Keeping authorization and tenant filtering intact with batching

A batch function receives only keys. It has no access to the resolver's context, so any authorization logic in the resolver runs before or after the load, never inside it. If a resolver checks permissions and then calls load(id), the check covers only that one call site. Any other resolver that loads the same ID gets the row without that check. There are two patterns that keep access control intact.

Scope the viewer or tenant into the loader

Construct loaders inside the request context factory and pass the viewer or tenant ID into the batch function through a closure. The batch query then includes WHERE tenant_id = $1 alongside id = ANY($2), so rows outside the tenant are never fetched. If a single request can act on behalf of more than one tenant, put the tenant in the key itself, as a composite such as tenantId:postId, and supply a cacheKeyFn so that identical pairs deduplicate correctly.

Authorize after loading

Load rows by ID only, then run a policy check on each result in the resolver or a shared helper before returning it. This suits rules that depend on row contents, such as ownership or visibility flags. Return null or an error for denied rows.

The dangerous variant is a loader shared across requests. When keys from several viewers end up in one batch, there is no single viewer to filter by. That forces two bad options: filter for nobody, or filter for whichever viewer triggered the batch. Its cache then serves rows to users who should not see them. Creating loaders per request rules this out structurally, because every batch and every cached entry belongs to exactly one authenticated viewer.

Verifying the fix and knowing when to avoid DataLoader

Count queries per request

Measure before you change anything. Wrap your database client so every executed query increments a counter stored on the request context, then log that count, along with the operation name, when the response is sent. Database-side statement logging works too, but a per-request counter lets you attribute queries to a specific GraphQL operation and assert on them in tests.

Run a fixed query, such as 20 posts with their comments and each comment's author, against seeded data. Without batching, the count grows with the data: one query for posts, one per post for comments, and one per comment for authors. With loaders in place, the count should drop to one query per level, which is three for this shape.

These queries run in sequence. The comment loader can't dispatch until the posts have resolved, and the author loader waits on the comments. That is expected. DataLoader removes per-item fan-out, not depth. Collapsing everything into one round trip requires a join or a query planner, which is a different technique. Lock the result in with a test that fails if the count for this operation goes above three.

When to skip or limit it

  • Single loads per request. If a field is fetched once, the loader adds scheduling and a cache map and saves no round trips.
  • Long-lived requests. The per-request cache keeps returning the first value it fetched. A long mutation sequence or a subscription-style request can read a row, write to it, and then get the stale cached copy back unless you clear that key.
  • Huge key sets. A large IN list can hit the database's bind-parameter limit or push the planner toward a bad plan. Capping batch size fixes this, and the reference JavaScript implementation has an option for it, but each extra chunk adds a round trip back.
  • Small, low-traffic schemas. Eager loading with explicit joins in the parent resolver may be simpler and fast enough.

Alternatives and impact on cost and engineering time

The options differ mainly in where the knowledge of "what to fetch next" lives.

Approach Round trips per level Depends on access pattern Complexity Maintenance
DataLoader 1 per relation Low Moderate, paid once Low with a shared template
Eager loading in parent 1 High: parent must predict children Low Grows with every new field
SQL joins 1 total High: tied to query shape Low to moderate Row explosion on nested lists
Lookahead on resolver info 1 or fewer Medium High Brittle with fragments and aliases
Leave N+1 on cold paths 1 + N None None Fine until traffic shifts

Illustrative estimate (assumed numbers, not a benchmark): latency ≈ round trips × per-query time. Assume 50 parents and 2 ms per query. Unbatched N+1 costs (1 + 50) × 2 = 102 ms. DataLoader costs (1 + 1) × 2 = 4 ms.

DataLoader has a real development cost. Every relation needs its own loader, someone must decide the cache key (ID alone, or ID plus tenant and arguments), and the loaders must be created and discarded with each request.

That cost is front-loaded, and it buys several things:

  • Simple, independent resolvers. Each field fetches only what it needs, without coordinating with its parent.
  • No per-query rewrites. Adding a field does not mean revisiting a join or a lookahead parser.
  • Fewer bugs from one tested template. A single batch-function template that preserves order, fills missing keys, and applies tenant filters removes a class of ordering and data-leak bugs from every future loader.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to graphql dataloader n+1, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.