Skip to content
intermediate Phase 9 · Solving the N+1 Problem with DataLoader

Configuring DataLoader

Creating loader instances with batch and cache functions that group concurrent requests.

50m
1 problems
Topic Progress 0%

The Loader Contract: Keys In, Ordered Values Out

The Loader Contract: Keys In, Ordered Values Out

A DataLoader is a promise cache wrapped around one function you write: hand it an array of keys, get back an array of values in the same order.

import DataLoader from 'dataloader';

const userLoader = new DataLoader(async (ids) => {
  const rows = await db.query('SELECT * FROM users WHERE id = ANY($1)', [ids]);
  const byId = new Map(rows.map((r) => [r.id, r]));
  return ids.map((id) => byId.get(id) ?? null);
});

Four lines carry the whole design:

  • ANY($1) — the batch statement. This is where N+1 disappears: one round trip no matter how many keys arrived.
  • byId — databases return rows in their own order (index scans, parallel workers), so you rebuild a lookup instead of trusting result order.
  • ?? null — a key with no row resolves to null, which GraphQL renders for a nullable field. Returning undefined for a miss is a contract violation: the batch function must return an array of the same length as the keys, or the loader rejects every promise in the batch.
  • load — the resolver entry point:
Post: { author: (post) => userLoader.load(post.authorId) }

load never queries the database itself. It appends the key to a pending queue and returns a promise immediately, so twenty-five resolvers calling it in a row cost nothing yet — the work happens when the queue flushes. loadMany([...]) does the same for arrays and preserves input order in its result.

The contract is positional: index i of your returned array answers key i of the input array. Swap two entries and the loader will confidently attach the wrong row to the wrong resolver, with no error anywhere.

The Batch Window: One Tick of the Event Loop

The Batch Window: One Tick of the Event Loop

DataLoader does not know about GraphQL. It only knows that keys queued before the next turn of the event loop belong together. In Node the batch dispatches on process.nextTick, after the current synchronous run-to-completion finishes.

That timing lines up exactly with how GraphQL executes a list: it walks the parents synchronously, calling each child resolver in the same tick, so every load for that level lands in one queue.

Caller pattern Batches Statements
25 synchronous load calls 1 1
10 loads of the same key 1 (memo hit) 1
25 loads each after await on unrelated I/O up to 25 up to 25

The third row is the trap. This resolver destroys batching:

Post: {
  author: async (post) => {
    const meta = await fetchMeta(post);          // the window closes here
    return userLoader.load(post.authorId);        // dispatches alone
  },
},

By the time load runs, the tick has ended and the queue contains one key. The fix is to issue the load first and let both promises settle together:

const [author, meta] = await Promise.all([
  userLoader.load(post.authorId),
  fetchMeta(post),
]);

Two options shape the window itself. maxBatchSize splits oversized queues (a batch of 5,000 keys becomes batches of 500, keeping SQL under parameter limits). batch: false disables batching entirely, leaving only the memo cache — useful for loaders whose batch function is already a single-row lookup.

The rule to remember: all load calls you want grouped must run in the same synchronous stretch. Anything awaited before the first load of a level risks splitting the group.

Failure semantics of a batch

One more contract detail matters when things go wrong: if the batch function throws, every promise in that batch rejects with the same error. There is no partial success — the loader cannot know which keys the failed statement would have satisfied, so it fails all of them and relies on your error handling at the field level. Batch functions that need per-key failure (one id malformed, the rest fine) should catch inside the function and return a sentinel or null for the bad key rather than throwing, reserving throw for "this whole batch could not run" — a connection outage, a timeout.

Also note what the loader does not do: it does not retry, does not stagger, and does not observe your transaction. If a batch fires while a surrounding transaction has rolled back, the keys resolve from data the caller may no longer be allowed to see — for transactional reads, build the loaders inside the request context that owns the connection, as covered in request-scoped loaders.

Cache Keys, Custom Maps, and Loader Maintenance

Cache Keys, Custom Maps, and Loader Maintenance

The loader memoizes every key it sees, and the memo identity is controlled by cacheKeyFn. The default is the raw value you passed, which works for scalars and quietly fails for objects:

// Two structurally identical objects are two distinct Map keys -> two queries
await loader.load({ tenantId: 'acme', id: 7 });
await loader.load({ tenantId: 'acme', id: 7 });

JavaScript Map keys compare by reference, so the second call misses the cache and dispatches a duplicate batch. Override the key function with everything that must participate in identity:

const invoiceLoader = new DataLoader(batchInvoices, {
  cacheKeyFn: (key) => key.tenantId + ':' + key.id,
});

Drop tenantId out of that string and two tenants' requests collide on id 7 — a correctness bug that becomes a data leak. Anything that changes which rows the batch returns belongs in the key: tenant, locale, or a data-version stamp.

When the default Map is wrong — unbounded growth on a hot loader — pass your own cacheMap:

new DataLoader(batchFn, { cacheMap: new LRUCache({ max: 5000 }) });

The map must expose get, set, delete, and clear, and its get should return a promise; synchronous lookups still fit because you can wrap values with Promise.resolve.

Maintenance API:

Method Effect Typical use
prime(key, value) seed a value without a query you already fetched the row
clear(key) evict one entry after a mutation touched that row
clearAll() drop every entry rare, deliberate resets only

clearAll on each request papers over a scoping bug; the durable fix is fresh instances per request, which request-scoped loaders covers. Caching and batching are also separate mechanisms with separate failure modes — batching versus caching untangles them.

Practice Problems

0 / 1 solved
Write a Scoped Batch Loader
DataLoader Batch Function

Implement a batch function for invoice rows keyed by { tenantId, id }. It must issue one statement for the whole batch, stay scoped to each key's tenant, return values aligned to input order, and resolve null for ids that do not exist in that tenant.

Example:

Input: keys = [{tenantId:"acme",id:1},{tenantId:"globex",id:1},{tenantId:"acme",id:99}]

Output: [invoice(acme,1), invoice(globex,1), null]

Each key carries its own tenant, so the batch groups by tenant rather than filtering globally; id 99 is missing under acme and therefore resolves to null at its own position.

Optimal Solution — O(N + R) time, O(R) space

Group keys by tenant, run one parameterized statement per tenant group, index rows by tenant:id, then map over the original key order

const batchInvoices = async (keys) => {
  // One statement per tenant group; ids passed as an array parameter
  const rows = await db.query(
    'SELECT tenant_id, id, total FROM invoices WHERE tenant_id = ANY($1) AND id = ANY($2)',
    [[...new Set(keys.map((k) => k.tenantId))], keys.map((k) => k.id)]
  );

  const byKey = new Map(
    rows.map((r) => [r.tenantId + ':' + r.id, r])
  );

  // Position i of the output answers key i of the input
  return keys.map((k) => byKey.get(k.tenantId + ':' + k.id) ?? null);
};

const invoiceLoader = new DataLoader(batchInvoices, {
  cacheKeyFn: (k) => k.tenantId + ':' + k.id,
});

Edge Cases:

  • Two tenants request the same numeric id — the composite cache key keeps their entries apart
  • Every key misses: return an array of nulls of matching length, never an empty array
  • Mixed tenant groups: filter each statement by its own tenant set so a row can never cross scopes

Quiz

1. A batch function receives keys [4, 9, 1] and the database returns rows in the order [1, 4, 9]. What must the function return?

Question 1 options

2. Twenty-five resolvers call userLoader.load(id) synchronously in the same execution block. How many batch function calls run?

Question 2 options

3. Why supply a cacheKeyFn when loading with a { tenantId, id } object key?

Question 3 options

Flashcards

Question

Batch function contract

Answer

Array of keys in, array of the same length out, position-aligned; null for keys with no row, never undefined.

Question

Batch window

Answer

Loads queued in the same synchronous stretch dispatch together on the next tick; awaiting I/O before load() splits the batch.

Question

cacheKeyFn

Answer

Builds the memo identity for a key; required for object/composite keys because Map keys compare by reference. Include tenant or scope in it.

Question

prime vs clear vs clearAll

Answer

prime seeds a known value without a query; clear evicts one key after a write; clearAll drops the map and should be rare.

Question

cacheMap

Answer

Any Map-like store (get/set/delete/clear, promise-returning get) — swap in an LRU to bound memory on hot loaders.

Revision Notes

Key Takeaways

  • 1. The batch function must return a same-length, position-aligned array; misses resolve to null
  • 2. Batching depends on synchronous load calls — any await before load() risks one query per row
  • 3. cacheKeyFn defines cache identity, so every scoping dimension (tenant, locale) belongs in the key

Interview Tips

  • • Walk the lifecycle of one load() call: enqueue, next-tick dispatch, batch query, positional zip, promise resolution
  • • Be ready to name the two failure modes of a wrong cacheKeyFn: duplicate queries and cross-scope key collisions

Cheat Sheet

DataLoader Configuration Cheat Sheet

Batch fn: (keys) => Promise<values[]> — same length, position-aligned, null for misses

Positional rule: output[i] answers input[i]; rebuild with new Map(rows.map(r => [r.id, r]))

Dispatch: next tick of the event loop; all load() calls in one synchronous stretch share a batch

Options

new DataLoader(batch, {
  batch: true,
  maxBatchSize: 500,
  cacheKeyFn: (k) => k.tenantId + ':' + k.id,
  cacheMap: new LRUCache({ max: 5000 }),
})

Maintenance: prime(k, v) seed · clear(k) evict after write · clearAll() rare reset

Debug rule: awaiting unrelated I/O before load() splits batches — start the load first

Never: return undefined, trust database row order, or share one instance across requests

Continue Learning