Batching by Hand: Collect, Fetch, Distribute
Batching by Hand: Collect, Fetch, Distribute
Not every codebase can take a DataLoader dependency. An ORM with a repository layer, a service that already groups queries, or a fixed join point in an older API can all batch with three plain steps: collect the parent ids, fetch the children with one statement, distribute them back.
The fetch itself is a repository method that takes many parents at once:
class PostRepository {
async findByAuthorIds(authorIds) {
const rows = await this.db.query(
'SELECT id, author_id, title FROM posts WHERE author_id = ANY($1)',
[authorIds] // one statement for all authors
);
const grouped = new Map(authorIds.map((id) => [id, []]));
for (const row of rows) grouped.get(row.authorId).push(row);
return authorIds.map((id) => grouped.get(id)); // one group per parent, in order
}
}
The caller side decides how ids are collected. In a GraphQL resolver, children resolve per parent, so you need a collector that holds keys until the current work settles — the same next-tick idea a loader library implements:
function makeBatcher(fetchBatch) {
let keys = [];
let pending = null;
return (key) => {
keys.push(key);
if (!pending) {
pending = new Promise((resolve, reject) => {
process.nextTick(async () => {
const batch = keys; keys = []; pending = null;
try { resolve(await fetchBatch(batch)); }
catch (err) { reject(err); }
});
});
}
return pending.then((rowsByKey) => rowsByKey.get(key));
};
}
const loadPostsByAuthor = makeBatcher((ids) => postRepo.findByAuthorIds(ids));
Twenty-five resolvers pushing keys in one tick produce one call to findByAuthorIds. When the parent field already arrives as a list — a REST adapter, a pipeline stage, a dataloader-free list resolver — the simplest version needs no collector at all: pass the ids straight to the repository method.
Order Is Not Guaranteed: The Reorder Map
Order Is Not Guaranteed: The Reorder Map
The most common hand-batching bug is trusting that results come back in the order you asked:
SELECT * FROM users WHERE id IN (9, 2, 5)
-- returns rows in index order or heap order: 2, 5, 9 — or any permutation
Postgres may answer with an index scan in key order, a bitmap scan in physical order, or a parallel scan split across workers. Nothing in SQL promises alignment with your array. Positional pairing then produces silent corruption:
// broken: row 0 belongs to id 9, not id 4
return ids.map((id, i) => rows[i]);
// correct: index by identity, then walk the input
const byId = new Map(rows.map((r) => [r.id, r]));
return ids.map((id) => byId.get(id) ?? null);
Two more cases the map handles cleanly:
Missing rows. An id with no match must map to null, not to the next row and not to undefined (which GraphQL treats as "no resolver result" and which breaks a batch-length contract if you are returning arrays).
One parent, many children. When the relation is one-to-many, group into arrays keyed by the parent and emit one group per input id, preserving input order:
| Input ids | Rows returned | Output |
|---|---|---|
[9, 2, 5] |
rows for 9 and 5 | [rows(9), null, rows(5)] |
If you also need determinism inside a group (comments ordered by creation time), add an ORDER BY — the reorder map fixes parent alignment, not child order.
Deduplicate before you query
The input array is rarely already clean. The same author id can appear on every row of a page, and a relationship expressed through a join table can repeat ids across several paths. Sending [7, 7, 7, 7] to the database asks the planner to scan the same key four times:
const unique = [...new Set(authorIds)]; // query once per distinct parent
const rows = await db.query(
'SELECT * FROM posts WHERE author_id = ANY($1)',
[unique]
);
// ...group by author_id, then map over the ORIGINAL array so output
// positions still line up with what the caller passed in
Deduplicate for the query, but keep one output slot per input entry — collapsing the output to unique ids silently shrinks a result list and shifts every later position. The same rule governs nulls: a missing parent must occupy its slot, not vanish from the array.
Safe IN Lists and When Hand-Rolling Wins
Safe IN Lists and When Hand-Rolling Wins
A dynamic id list must never reach SQL by string concatenation:
// broken — ids come from the request
const q = 'SELECT * FROM users WHERE id IN (' + ids.join(',') + ')';
// ids = ["1) OR 1=1 --"] dumps the table
// safe — one parameter, driver handles encoding
await db.query('SELECT * FROM users WHERE id = ANY($1)', [ids]);
// safe — expanded placeholders when the driver needs them
const placeholders = ids.map((_, i) => '$' + (i + 1)).join(',');
await db.query(
'SELECT * FROM users WHERE id IN (' + placeholders + ')',
ids
);
ANY($1) with an array parameter is the better default: one placeholder regardless of list length, no interpolation, no boundary to forget. Watch two operational limits — Postgres caps a statement at 65,535 bind parameters, and very large IN lists can flip the planner to a slow plan. Chunk at a few thousand ids per statement when parent lists can be unbounded (see maxBatchSize in DataLoader configuration).
When hand-rolling wins:
- The codebase already funnels queries through repositories, and adding a library means a second query path.
- The batch shape is known and stable — "load the comments for these five posts," resolved once per page, not per row.
- You want zero new dependencies in a security-sensitive service.
When it loses: you also need memoization across a request, cache key scoping, deduplication of in-flight loads, or eviction after writes. That is a loader library's job, and reimplementing it badly is how shared-cache leaks start — the failure mode described in request-scoped loaders.
A good interview answer names all three steps — collect, fetch with WHERE id IN, distribute via a map — then adds the two caveats: parameter order and row order are both promises SQL never makes.
Practice Problems
Given an array of author ids, fetch all of their posts in one statement and return one array of post groups per author id, in input order, with null-free empty groups for authors who have no posts. Missing authors must not shift later positions.
Example:
Input: authorIds = [9, 2, 5]; posts in DB: (author 9, "a1"), (author 5, "b1"), (author 5, "b2")
Output: [[a1], [], [b1, b2]]
One ANY($1) statement returns three rows in arbitrary order; grouping by author_id and then mapping over the input produces position 0 = author 9, position 1 = empty group for author 2, position 2 = both posts for author 5 in their returned order.
Optimal Solution — O(N + R) time, O(R) space
Bind the id array as a single parameter, group rows by parent id into arrays, then walk the input ids to build an aligned result
async function groupPostsByAuthor(db, authorIds) {
if (authorIds.length === 0) return [];
const { rows } = await db.query(
'SELECT id, author_id, title, created_at FROM posts ' +
'WHERE author_id = ANY($1) ORDER BY created_at DESC',
[authorIds] // one parameter, no interpolation
);
const grouped = new Map(authorIds.map((id) => [id, []]));
for (const row of rows) {
grouped.get(row.authorId).push(row); // unknown parents cannot appear
}
return authorIds.map((id) => grouped.get(id)); // input order, one group each
} Edge Cases:
- Empty id array: return [] without touching the database
- An id with no posts: emit an empty group so later positions stay aligned
- Duplicate ids in the input: decide once — dedupe the query but preserve one output position per input entry
Quiz
1. You request rows for ids [9, 2, 5] and the database returns rows for 9 and 5 only, in the order 5, 9. What is the correct aligned result?
2. Which query construction is safe for a list of ids that originates from the client?
3. Why rebuild a Map from the fetched rows instead of pairing rows with keys by position?
Flashcards
Question
Manual batching steps
Click to reveal answer
Answer
Collect parent ids → fetch children with one WHERE id IN / ANY statement → distribute results through an id-keyed map.
Question
Reorder rule
Click to reveal answer
Answer
Databases return rows in their own order; index results by identity and walk the input array, emitting null for misses.
Question
Safe dynamic IN list
Click to reveal answer
Answer
Bind the id array as one parameter (ANY($1)) or expand to $1..$n placeholders — never concatenate values into SQL text.
Question
One-to-many grouping
Click to reveal answer
Answer
Group children by parent id into arrays, then emit one group per input id to preserve parent order.
Question
Manual vs DataLoader
Click to reveal answer
Answer
Manual fits repository layers and fixed batches; loader libraries win when you also need memoization, scoped keys, and write invalidation.
Revision Notes
Key Takeaways
- 1. Batching is collect → one WHERE IN statement → distribute by identity, and every step is your responsibility without a library
- 2. Row order and parameter order are both unsafe assumptions: index by id, bind as an array
- 3. Hand-rolling covers the fetch; a loader library additionally covers memoization, cache keys, and eviction
Interview Tips
- • Write the reorder map unprompted — it is the detail interviewers use to tell implementers from describers
- • Name the parameter limit and chunking strategy for large id lists; it shows you have run this in production
Cheat Sheet
Manual Batch Loading Cheat Sheet
Three steps: collect ids → fetch once → distribute by identity
-- children for many parents, one statement
SELECT * FROM posts WHERE author_id = ANY($1);
const byId = new Map(rows.map((r) => [r.id, r]));
return ids.map((id) => byId.get(id) ?? null); // never rows[i]
// one-to-many
const grouped = new Map(ids.map((id) => [id, []]));
for (const row of rows) grouped.get(row.parentId).push(row);
return ids.map((id) => grouped.get(id));
Safety: ANY($1) with a bound array · expand to $1..$n if required · chunk near 1–2k ids
Never: IN (${ids.join(',')}) · positional pairing · returning undefined for misses
Add ORDER BY when child order inside a group must be deterministic