Skip to content

Batcher

createBatcher collects single-item load() calls for a few milliseconds and sends them as one batched request: 100 getUser(id) calls become one SELECT ... WHERE id IN (...). It is DataLoader’s (and bottleneck’s Batcher) idea, with two additions: each batch runs through a resilience pipeline as a unit, and cancellation works per caller. Use it to fix N+1 queries in GraphQL resolvers or fan-out code, or to fit a bulk API. Don’t use it when calls are rare (each one waits up to maxWait for company), or when the backend has no batch endpoint.

const
const pipeline: ResiliencePipeline<any, TimeoutRejectedError>
pipeline
= new
new ResiliencePipelineBuilder<any, never, never>(options?: ResiliencePipelineBuilderOptions): ResiliencePipelineBuilder<any, never, never>

Polly-style builder with a shortcut for every strategy: addRetry(), addTimeout(), addCircuitBreaker(), … Convenient, but it bundles every strategy. For the smallest browser bundle use PipelineBuilder with retryStrategy(), timeoutStrategy(), … instead; the two are interchangeable.

The type parameters track which errors the pipeline can raise, for pipeline.settle() + Outcome.match().

ResiliencePipelineBuilder
().
ResiliencePipelineBuilder<any, never, never>.addRetry(options?: RetryOptions<any>): ResiliencePipelineBuilder<any, never, never>
addRetry
({
RetryOptions<any>.maxRetryAttempts?: number | undefined

Retries in addition to the original call. Default 3. Infinity retries until aborted.

maxRetryAttempts
: 2 }).
ResiliencePipelineBuilder<any, never, never>.addTimeout(options: Duration | TimeoutOptions): Next<any, never, never, TimeoutRejectedError>

addTimeout(5000), addTimeout('5s') or addTimeout({ timeout: '5s', onTimeout }).

addTimeout
('1s').
ResiliencePipelineBuilder<any, TimeoutRejectedError, never>.build(): ResiliencePipeline<any, TimeoutRejectedError>
build
();
const
const users: Batcher<string, User>
users
=
createBatcher<string, User>(options: BatcherOptions<string, User>): Batcher<string, User>

DataLoader / bottleneck-Batcher style batching:

const users = createBatcher({ maxSize: 100, maxWait: '5ms', key: (id) => id, run: (ids, { signal }) => db.usersByIds(ids, { signal }) });
const [a, b] = await Promise.all([users.load('1'), users.load('2')]); // one query

createBatcher
({
BatcherOptions<I, R>.maxSize: number

Flush as soon as the batch holds this many (distinct) items.

maxSize
: 100,
BatcherOptions<I, R>.maxWait: Duration

Flush this long after the first item of a batch arrived.

maxWait
: '5ms',
BatcherOptions<string, User>.key?: ((item: string) => string) | undefined

Items with the same key share one slot in a batch (and one result). Required when run returns a Map.

key
: (
id: string
id
: string) =>
id: string
id
,
BatcherOptions<I, R>.pipeline?: ResiliencePipeline<any, any> | undefined

Each batch runs through it as a unit (retried, timed out, rate limited together).

pipeline
,
BatcherOptions<string, User>.run: (items: string[], context: ResilienceContext) => Promise<readonly (Error | User)[] | ReadonlyMap<string, Error | User>>

Loads a batch. Return one result per item, in order, or a Map by key. A result that is an Error rejects only that item. Gets the pipeline's context: pass context.signal on to your I/O.

run
: async (
ids: string[]
ids
, {
signal: AbortSignal

Aborted when the caller cancels, or when a strategy (timeout, hedging) gives up on this execution.

signal
}) => {
const
const rows: User[]
rows
= await
const db: {
usersByIds(ids: string[], options: {
signal: AbortSignal;
}): Promise<User[]>;
}
db
.
function usersByIds(ids: string[], options: {
signal: AbortSignal;
}): Promise<User[]>
usersByIds
(
ids: string[]
ids
, {
signal: AbortSignal
signal
});
return new
var Map: MapConstructor
new <string, User>(iterable?: Iterable<readonly [string, User]> | null | undefined) => Map<string, User> (+3 overloads)
Map
(
const rows: User[]
rows
.
Array<User>.map<[string, User]>(callbackfn: (value: User, index: number, array: User[]) => [string, User], thisArg?: any): [string, User][]

Calls a defined callback function on each element of an array, and returns an array that contains the results.

@param ― callbackfn A function that accepts up to three arguments. The map method calls the callbackfn function one time for each element in the array.

@param ― thisArg An object to which the this keyword can refer in the callbackfn function. If thisArg is omitted, undefined is used as the this value.

map
((
u: User
u
) => [
u: User
u
.
User.id: string
id
,
u: User
u
]));
},
});
// Three loads, two distinct ids: one query for ['1', '2'].
const [
const a: User
a
,
const b: User
b
,
const c: User
c
] = await
var Promise: PromiseConstructor

Represents the completion of an asynchronous operation

Promise
.
PromiseConstructor.all<[Promise<User>, Promise<User>, Promise<User>]>(values: [Promise<User>, Promise<User>, Promise<User>]): Promise<[User, User, User]> (+1 overload)

Creates a Promise that is resolved with an array of results when all of the provided Promises resolve, or rejected when any Promise is rejected.

@param ― values An array of Promises.

@returns ― A new Promise.

all
([
const users: Batcher<string, User>
users
.
Batcher<string, User>.load(item: string, options?: {
signal?: AbortSignal;
}): Promise<User>

Queues the item for the next batch. Aborting signal (default: the ambient pipeline's signal) rejects this call and, until the batch starts, drops the item from it.

load
('1'),
const users: Batcher<string, User>
users
.
Batcher<string, User>.load(item: string, options?: {
signal?: AbortSignal;
}): Promise<User>

Queues the item for the next batch. Aborting signal (default: the ambient pipeline's signal) rejects this call and, until the batch starts, drops the item from it.

load
('2'),
const users: Batcher<string, User>
users
.
Batcher<string, User>.load(item: string, options?: {
signal?: AbortSignal;
}): Promise<User>

Queues the item for the next batch. Aborting signal (default: the ambient pipeline's signal) rejects this call and, until the batch starts, drops the item from it.

load
('1')]);
0 ms1 ms1 ms
collecting (≤ maxWait)run(items), one call per batchload() joinsresolved idsrejected (u6 has no row)Colour = batch. Each dot is one load(); equal ids in a batch share one slot (dedupe by key), so ×n counts distinct ids.

    0 loads → 0 batches with 0 distinct ids; average wait per load 0 ms.

    createBatcher({ maxSize: 4, maxWait: 10, key: (id) => id, run: (ids) => usersByIds(ids) })
    1. The first load() of a batch starts a maxWait timer.
    2. Each load() adds its item to the pending batch. With key, items with the same key share one slot and one result.
    3. The batch is sent when it holds maxSize distinct items, or when the timer fires, whichever comes first. The next load() starts a new batch.
    4. run(items, context) is executed once per batch through pipeline: retried, timed out and rate limited as a unit.
    5. Results are routed back to each caller.
    Return Matching Missing result
    An array One result per item, in the order of items A length mismatch rejects every item of the batch.
    A Map (needs key) By key That item rejects with Batcher: no result for key ....

    Either way, a result that is an Error instance rejects only that item; the others resolve. If run throws (after the pipeline’s retries), every item of the batch rejects with that error.

    const
    const prices: Batcher<string, Price>
    prices
    =
    createBatcher<string, Price>(options: BatcherOptions<string, Price>): Batcher<string, Price>

    DataLoader / bottleneck-Batcher style batching:

    const users = createBatcher({ maxSize: 100, maxWait: '5ms', key: (id) => id, run: (ids, { signal }) => db.usersByIds(ids, { signal }) });
    const [a, b] = await Promise.all([users.load('1'), users.load('2')]); // one query

    createBatcher
    ({
    BatcherOptions<I, R>.maxSize: number

    Flush as soon as the batch holds this many (distinct) items.

    maxSize
    : 50,
    BatcherOptions<I, R>.maxWait: Duration

    Flush this long after the first item of a batch arrived.

    maxWait
    : '10ms',
    BatcherOptions<string, Price>.run: (items: string[], context: ResilienceContext) => Promise<readonly (Price | Error)[] | ReadonlyMap<string, Price | Error>>

    Loads a batch. Return one result per item, in order, or a Map by key. A result that is an Error rejects only that item. Gets the pipeline's context: pass context.signal on to your I/O.

    run
    : async (
    skus: string[]
    skus
    : string[], {
    signal: AbortSignal

    Aborted when the caller cancels, or when a strategy (timeout, hedging) gives up on this execution.

    signal
    }) => {
    const
    const found: (Price | null)[]
    found
    = await
    function priceApi(skus: string[], signal: AbortSignal): Promise<Array<Price | null>>
    priceApi
    (
    skus: string[]
    skus
    ,
    signal: AbortSignal

    Aborted when the caller cancels, or when a strategy (timeout, hedging) gives up on this execution.

    signal
    ); // same order as skus, null when unknown
    return
    const found: (Price | null)[]
    found
    .
    Array<Price | null>.map<Price | Error>(callbackfn: (value: Price | null, index: number, array: (Price | null)[]) => Price | Error, thisArg?: any): (Price | Error)[]

    Calls a defined callback function on each element of an array, and returns an array that contains the results.

    @param ― callbackfn A function that accepts up to three arguments. The map method calls the callbackfn function one time for each element in the array.

    @param ― thisArg An object to which the this keyword can refer in the callbackfn function. If thisArg is omitted, undefined is used as the this value.

    map
    ((
    p: Price | null
    p
    ,
    i: number
    i
    ) =>
    p: Price | null
    p
    ?? new
    var Error: ErrorConstructor
    new (message?: string, options?: ErrorOptions) => Error (+1 overload)
    Error
    (`unknown sku ${
    skus: string[]
    skus
    [
    i: number
    i
    ]}`));
    },
    });
    const
    const price: Price
    price
    = await
    const prices: Batcher<string, Price>
    prices
    .
    Batcher<string, Price>.load(item: string, options?: {
    signal?: AbortSignal;
    }): Promise<Price>

    Queues the item for the next batch. Aborting signal (default: the ambient pipeline's signal) rejects this call and, until the batch starts, drops the item from it.

    load
    ('sku-1'); // rejects only if this sku is unknown

    Without key, every load() gets its own slot, even for equal items.

    Option Type Default Description
    maxSize number required Send the batch as soon as it holds this many distinct items. Positive integer.
    maxWait Duration required Send the batch this long after its first item arrived. A few ms is typical: it is added to the latency of the first caller.
    run (items, context) => Promise<Array<R | Error> | Map<string, R | Error>> required Loads a batch. Gets the pipeline’s context: pass context.signal to your I/O.
    key (item) => string – Dedupe: items with the same key share one slot and one result. Required when run returns a Map.
    pipeline ResiliencePipeline the empty pipeline Each batch runs through it as a unit.
    timeProvider TimeProvider system clock Clock for maxWait, e.g. FakeTimeProvider in tests.

    load(item, { signal? }) returns a promise of that item’s result.

    A batch is shared by many callers, so one caller can’t cancel it for the others.

    • Before the batch is sent, aborting a caller’s signal rejects that load() with the abort reason and removes its item, unless another caller is waiting on the same key.
    • After the batch is sent, aborting rejects that caller’s load() only. The batch keeps running for everyone else.
    • Default signal. Without signal, load() uses the signal of the pipeline execution it is called from (the ambient context), if any. So a load() inside pipeline.execute() gives up when that execution times out.
    • The batch has its own signal. run doesn’t run inside the execution that happened to trigger the flush, so that caller’s deadline doesn’t cancel the batch. Put a timeout in the batcher’s own pipeline.
    • Batch size limits. Set maxSize at or below what the backend accepts in one request (IN (...) limits, bulk API maximums).
    • Order. An array result must follow the order of items, not the order the backend chose. Return a Map when the backend reorders or skips rows.
    • No caching. A batcher only merges concurrent loads. Put a cache in front for repeat reads across time.