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;
const BatchLoader = require('./BatchLoader');
async function batchUsers(ids, db) {
const { rows } = await db.query(
'SELECT id, name, email, avatar_url FROM users WHERE id = ANY($1)',
[ids]
);
const byId = new Map(rows.map((row) => [row.id, row]));
// Reindex into the exact order the loader requested the keys.
return ids.map((id) => byId.get(id) || null);
}
function createUserLoader(db) {
return new BatchLoader((ids) => batchUsers(ids, db));
}
module.exports = { createUserLoader };
const { createUserLoader } = require('./userLoader');
function buildContext(db) {
return () => ({
db,
loaders: {
user: createUserLoader(db),
},
});
}
const resolvers = {
Query: {
posts: (_root, _args, { db }) =>
db.query('SELECT * FROM posts ORDER BY created_at DESC LIMIT 50')
.then((r) => r.rows),
},
Post: {
// Called once per post, but all ids collapse into one query per tick.
author: (post, _args, { loaders }) => loaders.user.load(post.authorId),
},
};
module.exports = { resolvers, buildContext };
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
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
createdAt: String!
GraphQL API with Spring Boot
use crossbeam::channel::unbounded;
use std::thread;
fn main() {
let (tx, rx) = unbounded();
Crossbeam for advanced concurrent data structures
// Creating a Promise
const myPromise = new Promise((resolve, reject) => {
const success = true;
setTimeout(() => {
if (success) {
Promises and async/await patterns for asynchronous JavaScript
Share this code
Here's the card — post it anywhere.