javascript 96 lines · 3 tabs

Coalescing Concurrent Reads with a DataLoader-Style Batch Loader in Node

Shared by codesnips Jul 2026
3 tabs
class BatchLoader {
  constructor(batchFn, { cacheKeyFn = (k) => k } = {}) {
    this.batchFn = batchFn;
    this.cacheKeyFn = cacheKeyFn;
    this.cache = new Map();
    this.queue = [];
  }

  load(key) {
    const cacheKey = this.cacheKeyFn(key);
    if (this.cache.has(cacheKey)) return this.cache.get(cacheKey);

    const promise = new Promise((resolve, reject) => {
      this.queue.push({ key, resolve, reject });
    });
    this.cache.set(cacheKey, promise);

    if (this.queue.length === 1) {
      process.nextTick(() => this.flush());
    }
    return promise;
  }

  loadMany(keys) {
    return Promise.all(keys.map((k) => this.load(k)));
  }

  flush() {
    const batch = this.queue;
    this.queue = [];
    if (batch.length === 0) return;

    const keys = batch.map((e) => e.key);
    Promise.resolve()
      .then(() => this.batchFn(keys))
      .then((results) => this.dispatch(batch, results))
      .catch((err) => batch.forEach((e) => e.reject(err)));
  }

  dispatch(batch, results) {
    if (!Array.isArray(results) || results.length !== batch.length) {
      const err = new Error('batchFn must return an array matching keys length');
      return batch.forEach((e) => e.reject(err));
    }
    batch.forEach((entry, i) => {
      const value = results[i];
      if (value instanceof Error) entry.reject(value);
      else entry.resolve(value == null ? null : value);
    });
  }
}

module.exports = BatchLoader;
3 files · javascript Explain with highlit

A DataLoader-style coalescer solves the N+1 problem that appears whenever many independent code paths each ask for a single record by id. Instead of firing one query per request, the loader buffers keys within a single tick of the event loop and dispatches one batched query, then fans the results back out to each caller. This is essential in GraphQL resolvers where each field resolves independently and would otherwise emit hundreds of duplicate SELECTs.

In BatchLoader.js, load(key) returns a promise immediately and pushes an entry into this.queue. The first load of a batch calls process.nextTick(() => this.flush()), scheduling the actual dispatch to run after all synchronous resolver code has queued its keys. Using nextTick (a microtask-adjacent hook) means every load issued in the same tick collapses into one flush. Keys are deduplicated through this.cache, a per-instance Map, so requesting the same id twice reuses one promise — the loader is both a coalescer and a request-scoped cache.

flush snapshots the queue, resets it, and calls the user-supplied batchFn with the unique keys. The contract mirrors DataLoader's: batchFn must return results in the same order as the keys, and dispatch maps each result back to its waiting resolve/reject. Missing rows resolve to null rather than throwing, which keeps optional relationships working. A rejected batch rejects every pending caller so no promise is left hanging.

userLoader.js shows the real payoff: batchUsers receives an array of ids and issues a single WHERE id = ANY($1) query, then reindexes rows into key order via a lookup Map. Because SQL returns rows in arbitrary order, this reindex step is mandatory — returning them raw would misalign results with keys.

resolvers.js wires a fresh loader per request in context, which is critical: caching must be scoped to one request so stale data never leaks between users. The Post.author resolver simply calls loaders.user.load(post.authorId), and dozens of concurrent calls collapse into one query. The main trade-off is that batching adds a tick of latency and the cache grows for the request's lifetime, so loaders are created and discarded per request rather than shared globally.


Related snips

Share this code

Here's the card — post it anywhere.

Coalescing Concurrent Reads with a DataLoader-Style Batch Loader in Node — share card
Link copied