Batcher — N+1 Coalescing & Request Demultiplexing
2.84 kB minified + gzipped
When rendering relational data — such as a list of posts displaying each author’s profile or an order feed resolving item details — naive implementations fetch child records inside iterative loops. Each parent item issues an independent database query or HTTP request.
This pattern produces three severe runtime hazards:
- Connection Pool Exhaustion: A page of 50 posts sends 50 sequential round trips to the database, saturating connection pools and serializing I/O.
- Redundant Key Lookups: If 20 posts are written by the same author, the database receives 20 identical queries for that author ID within the same execution turn.
- GraphQL Resolver Fragmentation: In GraphQL schemas, field resolvers execute independently. An
authorfield resolver on aPosttype has no native knowledge of sibling resolvers running in the same query tree.
1. The Minimal Atomic Fix
Section titled “1. The Minimal Atomic Fix”Batcher collects individual key lookups called synchronously in the same event loop tick,
deduplicates redundant keys, and dispatches a single bulk query.
Batcher.make returns a stateless loader. Once the active microtask turn finishes and its pending
callers settle, the batch buffer clears. The batcher can safely live as a long-lived application
singleton without memory leaks or cross-request stale state.
flowchart TD
subgraph S1["1. Synchronous Coalescing (Microtask Turn)"]
direction TB
C1["caller1: load(u_1)"]
C2["caller2: load(u_1)"]
C3["caller3: load(u_2)"]
end
subgraph S2["2. Batch Buffer & Deduplication"]
direction TB
Dedupe["Deduplicate Keys: (u_1, u_2)"]
Chunk["Partition into chunks if > maxBatchSize"]
end
subgraph S3["3. Bulk Execution"]
direction TB
Fetch["fetch((u_1, u_2)) -> ReadonlyMap"]
end
subgraph S4["4. Per-Caller Demultiplexing"]
direction TB
O1["caller1, caller2 -> BatcherOk(User 1)"]
O2["caller3 -> BatcherMissing(u_2)"]
O3["Query Failure -> BatcherErr(DbError)"]
end
S1 --> S2
S2 --> S3
S3 --> S4
2. Tri-State Outcomes (Batcher.Outcome)
Section titled “2. Tri-State Outcomes (Batcher.Outcome)”When querying a database for a collection of IDs (WHERE id IN ('u_1', 'u_2')), missing rows are a
normal domain reality, not an unexpected query failure.
Batcher separates discovery into a tri-state discriminated union:
BatcherOk: The key was found in the returned map. Carriesvalue: V.BatcherMissing: The batch query succeeded, but the requested key was absent from the map. Carrieskey: K.BatcherErr: The batch query itself failed (e.g., database network timeout). Carrieserror: E.
3. Pattern Matching & Elimination
Section titled “3. Pattern Matching & Elimination”Eliminate a Batcher.Outcome using Batcher.Outcome.match to handle all three branches explicitly:
Transforming & Extracting Values
Section titled “Transforming & Extracting Values”When you only need to transform the successful value or extract it with a fallback:
Direct Domain Conversions
Section titled “Direct Domain Conversions”Batcher.Outcome.to provides standard adapters to convert outcomes into primary core types:
4. Operational Controls
Section titled “4. Operational Controls”Bounded Chunks (maxBatchSize)
Section titled “Bounded Chunks (maxBatchSize)”Relational databases impose limits on query parameters (SQLite allows up to 999 parameters; Postgres allows up to 65,535). If an application queues 5,000 keys in a single tick, sending them in one query causes database drivers to throw parameter overflow errors.
Setting maxBatchSize automatically partitions oversized batches into bounded chunks:
Concurrency Limiting (concurrency)
Section titled “Concurrency Limiting (concurrency)”When a large batch is split into multiple chunks, executing all chunks in parallel can saturate
database connection pools. Use concurrency to bound in-flight database dispatches:
Delayed Coalescing Windows (window)
Section titled “Delayed Coalescing Windows (window)”By default, Batcher flushes on the next microtask turn (queueMicrotask). When coordinating
asynchronous message queues, background workers, or high-throughput event consumers, buffering over
a fixed time window yields higher coalescing ratios:
Caller Abort Isolation (AbortSignal)
Section titled “Caller Abort Isolation (AbortSignal)”When client requests disconnect or reach gateway timeouts, individual callers can cancel their lookup without interrupting sibling requests sharing the same batch:
If every caller in a pending batch aborts before the query dispatches, the batcher cancels the
pending batch and skips calling fetch entirely.
5. Real-World Architectural Patterns
Section titled “5. Real-World Architectural Patterns”Relational Database Entity Access (1-to-1 Mapping)
Section titled “Relational Database Entity Access (1-to-1 Mapping)”When fetching entities by primary key, pair fetch with Arr.indexBy:
One-to-Many Relation Aggregation (1-to-N Mapping)
Section titled “One-to-Many Relation Aggregation (1-to-N Mapping)”When fetching child records grouped under a parent ID (such as blog post comments or order line
items), pair fetch with Dict.groupBy:
GraphQL Field Resolvers
Section titled “GraphQL Field Resolvers”In GraphQL servers (GraphQL Yoga, Apollo, Mercurius), field resolvers execute independently across
sibling nodes in the selection set. Return Deferred lookups directly, mapped through
to.nullable:
6. Deterministic Testing (flush)
Section titled “6. Deterministic Testing (flush)”Unit testing microtask batchers often leads to flaky tests with arbitrary setTimeout delays.
batcher.flush() drains and settles all active and in-flight batches deterministically:
Problems it solves
Section titled “Problems it solves”- N+1 Query Elimination: Consolidates independent
.load()invocations across loops or GraphQL trees into single bulk queries. - Microtask Key Deduplication: Collapses duplicate key requests within the same tick so backend databases never fetch the same entity twice.
- Parameter Overflow Shielding: Automatically splits large batches exceeding database parameter
limits (
maxBatchSize) without manual chunking code. - Tri-State Domain Clarity: Distinguishes between successful values, missing keys, and execution errors without throwing exceptions or overloading error channels.
- Memory-Leak Free Singletons: Retains zero state across execution turns, allowing safe long-lived application singleton instances.