Skip to content

@absoluteui/helpers/async

Abortable sleep, timeouts, retry with backoff, concurrency limits, batching, and polling.

import { ... } from '@absoluteui/helpers/async';

Promise control that browsers do not ship: timeouts that really cancel, retries with backoff, concurrency limits, request batching and de-duplication, polling, and AbortSignal plumbing. Every helper accepts a signal, and cancellation rejects with an AbortError (HLP_ABORTED) that retries never repeat. The Helpers and services guide has a basic retry example; for HTTP, the services client already retries and times out (see the HTTP guide).

How do I process many items with a concurrency limit and report the failures?

Section titled “How do I process many items with a concurrency limit and report the failures?”

mapLimitSettled keeps at most limit calls in flight, returns one result per item in input order, and never rejects for an item failure. partitionSettled splits the results for a summary. Pass a signal so the user can stop the whole run.

import { mapLimitSettled, partitionSettled, sleep } from '@absoluteui/helpers/async';
const payslips = ['E-1001', 'E-1002', 'E-1003', 'E-1004', 'E-1005'];
const controller = new AbortController();
let inFlight = 0;
let peak = 0;
const results = await mapLimitSettled(payslips, 2, async (id, _i, signal) => {
peak = Math.max(peak, ++inFlight);
await sleep(5, { signal });
inFlight--;
if (id === 'E-1003') throw new Error('No bank account on file');
return `${id}.pdf`;
}, { signal: controller.signal });
const { fulfilled, rejected } = partitionSettled(results);
fulfilled; // => ['E-1001.pdf', 'E-1002.pdf', 'E-1004.pdf', 'E-1005.pdf']
rejected.length; // => 1
peak; // => 2

Use mapLimit instead when one failure should stop the rest, and createLimiter when tasks arrive over time.

How do I retry a slow or flaky call with a deadline per attempt?

Section titled “How do I retry a slow or flaky call with a deadline per attempt?”

Wrap each attempt in timeout with a function, so the attempt’s signal aborts the real work when time runs out. retry then tries again with exponential backoff; retryIf decides which errors are worth it.

import { retry, sleep, timeout } from '@absoluteui/helpers/async';
import { TimeoutError } from '@absoluteui/helpers/error';
const delays = [300, 300, 1]; // the service is slow twice, then answers
const retried: number[] = [];
const loadBalance = (signal: AbortSignal, attempt: number) => sleep(delays[attempt - 1] ?? 1, { signal }).then(() => ({ leaveDays: 12 }));
const balance = await retry((attempt, signal) => timeout((s) => loadBalance(s, attempt), 60, { signal }), {
retries: 3,
minDelay: 1,
jitter: 'none',
retryIf: (e) => e instanceof TimeoutError,
onRetry: (_e, attempt) => retried.push(attempt),
});
balance; // => { leaveDays: 12 }
retried; // => [1, 2]

How do I wait for a long-running job to finish?

Section titled “How do I wait for a long-running job to finish?”

poll calls your function, waits interval between calls, and stops when until holds. timeout bounds the whole wait (HLP_TIMEOUT), and the signal lets the user cancel.

import { poll } from '@absoluteui/helpers/async';
let checks = 0;
const checkExport = async () => ({ id: 'X-9', status: ++checks < 3 ? 'running' : 'ready', url: '/exports/X-9.zip' });
const job = await poll(checkExport, { until: (j) => j.status !== 'running', interval: 5, timeout: 1000 });
[job.status, checks]; // => ['ready', 3]
await poll(async () => ({ status: 'running' }), { until: (j) => j.status !== 'running', interval: 5, timeout: 20 }); // => throws HLP_TIMEOUT

How do I turn many lookups into one request?

Section titled “How do I turn many lookups into one request?”

createBatcher collects load(key) calls made in the same tick and resolves them from one loadMany call, such as a grid rendering 50 manager names. singleFlight shares one in-flight promise between callers that ask for the same thing at the same time.

import { createBatcher, singleFlight } from '@absoluteui/helpers/async';
const requests: string[][] = [];
const people = createBatcher(async (ids: string[]) => {
requests.push(ids);
return ids.map((id) => (id === 'E-0' ? new Error('Unknown employee') : { id, name: `Employee ${id}` }));
}, { maxBatchSize: 100 });
const [asha, liam, missing] = await Promise.allSettled([people.load('E-1'), people.load('E-2'), people.load('E-0')]);
requests; // => [['E-1', 'E-2', 'E-0']]
[asha.status, liam.status, missing.status]; // => ['fulfilled', 'fulfilled', 'rejected']
let loads = 0;
const holidays = singleFlight(async (year: number) => { loads++; return [`${String(year)}-12-25`]; });
await Promise.all([holidays(2026), holidays(2026), holidays(2027)]);
loads; // => 2

How do I keep saves in the order the user made them?

Section titled “How do I keep saves in the order the user made them?”

A serial queue runs one task at a time in submission order, so a slow first save cannot land after a fast second one. onIdle resolves when everything queued has finished, for example before closing an editor.

import { createSerialQueue, sleep } from '@absoluteui/helpers/async';
const queue = createSerialQueue();
const stored: string[] = [];
const save = (text: string, ms: number) => queue.schedule(async (signal) => { await sleep(ms, { signal }); stored.push(text); });
void save('Draft 1', 20);
void save('Draft 2', 0);
await queue.onIdle();
stored; // => ['Draft 1', 'Draft 2']
  • raceSignal and a plain promise passed to timeout stop waiting but do not stop the work; pass a function to timeout, or the signal to the work itself.
  • Aborts are not retried, and cancelled work rejects with HLP_ABORTED: ignore that code instead of showing an error.
  • Delays over MAX_DELAY_MS (about 24.8 days) are rejected, because setTimeout would fire them at once.
import { MAX_DELAY_MS, sleep, withCancel } from '@absoluteui/helpers/async';
const search = withCancel((signal) => sleep(10_000, { signal }).then(() => ['Asha Rao']));
search.cancel();
await search.promise; // => throws HLP_ABORTED
await sleep(MAX_DELAY_MS + 1); // => throws HLP_ASYNC_INVALID_MS

asyncPipe

Async left-to-right composition; each step may return a promise.

function asyncPipe<A extends unknown[], B>(ab: (...a: A) => Awaitable<B>): (...a: A) => Promise<B>;
function asyncPipe<A extends unknown[], B, C>(ab: (...a: A) => Awaitable<B>, bc: (b: B) => Awaitable<C>): (...a: A) => Promise<C>;
function asyncPipe<A extends unknown[], B, C, D>(ab: (...a: A) => Awaitable<B>, bc: (b: B) => Awaitable<C>, cd: (c: C) => Awaitable<D>): (...a: A) => Promise<D>;
function asyncPipe<A extends unknown[], B, C, D, E>(ab: (...a: A) => Awaitable<B>, bc: (b: B) => Awaitable<C>, cd: (c: C) => Awaitable<D>, de: (d: D) => Awaitable<E>): (...a: A) => Promise<E>;
function asyncPipe<A extends unknown[], B, C, D, E, F>(ab: (...a: A) => Awaitable<B>, bc: (b: B) => Awaitable<C>, cd: (c: C) => Awaitable<D>, de: (d: D) => Awaitable<E>, ef: (e: E) => Awaitable<F>): (...a: A) => Promise<F>;
function asyncPipe<A extends unknown[], B, C, D, E, F, G>(ab: (...a: A) => Awaitable<B>, bc: (b: B) => Awaitable<C>, cd: (c: C) => Awaitable<D>, de: (d: D) => Awaitable<E>, ef: (e: E) => Awaitable<F>, fg: (f: F) => Awaitable<G>): (...a: A) => Promise<G>;
function asyncPipe<A extends unknown[], B, C, D, E, F, G, H>(ab: (...a: A) => Awaitable<B>, bc: (b: B) => Awaitable<C>, cd: (c: C) => Awaitable<D>, de: (d: D) => Awaitable<E>, ef: (e: E) => Awaitable<F>, fg: (f: F) => Awaitable<G>, gh: (g: G) => Awaitable<H>): (...a: A) => Promise<H>;

Example:

const employees = new Map([['u1', { name: 'Ada', deptId: 'd7' }]]);
const departmentOf = asyncPipe(
async (id: string) => employees.get(id),
(e) => e?.deptId ?? 'unassigned',
async (deptId) => `Department ${deptId}`,
);
await departmentOf('u1'); // => 'Department d7'

collectAsync

Collects an async (or sync) iterable into an array, optionally capped at limit items.

function collectAsync<T>(source: AsyncIterable<T> | Iterable<T>, options?: CollectOptions): Promise<T[]>;

Example:

async function* rows() {
for (let i = 1; i <= 1_000; i++) yield i;
}
await collectAsync(rows(), { limit: 3 }); // => [1, 2, 3]

createBatcher

DataLoader-style batching: collects load(key) calls and resolves them from one loadMany(keys) call. loadMany must return one result per key in the same order; an Error element rejects just that key. No caching across batches (combine with memoize or a cache if needed).

function createBatcher<K, V>(loadMany: (keys: K[]) => PromiseLike<readonly (V | Error)[]> | readonly (V | Error)[], options?: BatcherOptions<K>): Batcher<K, V>;

Example:

const requests: string[][] = [];
const users = createBatcher((ids: string[]) => {
requests.push(ids);
return ids.map((id) => ({ id, name: `Employee ${id}` }));
});
const [a, b] = await Promise.all([users.load('1'), users.load('2')]);
b; // => { id: '2', name: 'Employee 2' }
requests; // => [['1', '2']] // one request for both keys

debounceAsync

Debounces an async function so only the latest arguments are executed. Choice: every caller whose call was superseded receives the result of the latest call (not an AbortError), so all awaiting UI settles on fresh data; results of runs superseded while in flight are discarded. cancel() rejects waiters with AbortError.

function debounceAsync<A extends unknown[], R>(fn: (...args: A) => PromiseLike<R> | R, wait: number): DebouncedAsync<A, R>;

Example:

const search = debounceAsync(async (q: string) => [`${q}: 3 matches`], 20);
const [first, latest] = await Promise.all([search('ad'), search('ada')]);
first; // => ['ada: 3 matches'] // superseded callers get the latest result
latest; // => ['ada: 3 matches']

singleFlight

De-duplicates concurrent calls: while a call for a key is in flight, callers with the same key share its promise.

function singleFlight<A extends unknown[], R>(fn: (...args: A) => PromiseLike<R> | R, keyFn?: (...args: A) => unknown): (...args: A) => Promise<R>;

Example:

let fetches = 0;
const getConfig = singleFlight(async (env: string) => {
fetches++;
return { env, currency: 'EUR' };
});
await Promise.all([getConfig('prod'), getConfig('prod')]);
fetches; // => 1

toAsyncIterable

Wraps a sync iterable (of values or promises) or an async iterable as an async iterable.

function toAsyncIterable<T>(source: Iterable<T | PromiseLike<T>> | AsyncIterable<T>): AsyncGenerator<T>;

Example:

const names: string[] = [];
for await (const name of toAsyncIterable([Promise.resolve('Ada'), 'Grace'])) names.push(name);
names; // => ['Ada', 'Grace']

Batcher

Batching loader returned by createBatcher.

interface Batcher<K, V> {
load(key: K): Promise<V>;
loadMany(keys: readonly K[]): Promise<V[]>;
flush(): void;
}

BatcherOptions

interface BatcherOptions<K> {
readonly maxBatchSize?: number;
readonly delay?: number;
readonly cacheKey?: (key: K) => unknown;
}

CollectOptions

interface CollectOptions extends SignalOptions {
readonly limit?: number;
}

DebouncedAsync

A debounced async function.

interface DebouncedAsync<A extends unknown[], R> {
(...args: A): Promise<R>;
cancel(): void;
}

createLimiter

Runs scheduled async tasks with at most concurrency in flight (FIFO). O(1) per schedule/start/cancel.

function createLimiter(concurrency: number): Limiter;

Example:

const limiter = createLimiter(2);
let peak = 0;
const task = async (): Promise<void> => {
peak = Math.max(peak, limiter.active);
await sleep(5);
};
await Promise.all([1, 2, 3, 4].map(() => limiter.schedule(task)));
peak; // => 2

createSerialQueue

Runs async tasks one at a time in submission order (a limiter with concurrency 1).

function createSerialQueue(): Limiter;

Example:

const writes = createSerialQueue();
const saved: string[] = [];
const save = async (id: string): Promise<void> => {
await sleep(id === 'a' ? 10 : 0);
saved.push(id);
};
await Promise.all([writes.schedule(() => save('a')), writes.schedule(() => save('b'))]);
saved; // => ['a', 'b'] // b starts after a finishes

forEachAsync

Calls fn for each item sequentially, awaiting each call (works with sync and async iterables).

function forEachAsync<T>(items: Iterable<T> | AsyncIterable<T>, fn: (item: T, index: number, signal: AbortSignal | undefined) => unknown, options?: SignalOptions): Promise<void>;

Example:

const applied: string[] = [];
await forEachAsync(['001_create', '002_seed'], async (name, i) => {
applied.push(`${i}:${name}`);
});
applied; // => ['0:001_create', '1:002_seed']

mapLimit

Maps items through async fn with at most limit calls in flight, preserving input order. Items are pulled lazily from the iterable. fn receives a signal that aborts on outer abort or first failure.

function mapLimit<T, R>(items: Iterable<T>, limit: number, fn: (item: T, index: number, signal: AbortSignal) => PromiseLike<R> | R, options?: MapLimitOptions): Promise<R[]>;

Example:

const ids = [3, 1, 2];
await mapLimit(ids, 2, async (id) => `EMP-${id}`); // => ['EMP-3', 'EMP-1', 'EMP-2']

mapLimitSettled

Like mapLimit but never rejects for item failures: resolves with a settled result per item, in order. Still rejects with AbortError when signal aborts.

function mapLimitSettled<T, R>(items: Iterable<T>, limit: number, fn: (item: T, index: number, signal: AbortSignal) => PromiseLike<R> | R, options?: SignalOptions): Promise<PromiseSettledResult<R>[]>;

Example:

const files = ['staff.csv', 'photo.png'];
const results = await mapLimitSettled(files, 3, async (f) => {
if (!f.endsWith('.csv')) throw new Error('Only CSV files can be imported.');
return f;
});
const { fulfilled, rejected } = partitionSettled(results);
fulfilled; // => ['staff.csv']
rejected.length; // => 1

partitionSettled

Splits Promise.allSettled output into values and reasons, keeping order within each.

function partitionSettled<T>(results: readonly PromiseSettledResult<T>[]): {
fulfilled: T[];
rejected: unknown[];
};

Example:

const results = await Promise.allSettled([Promise.resolve(1), Promise.reject(new Error('Failed.')), Promise.resolve(3)]);
partitionSettled(results).fulfilled; // => [1, 3]

Limiter

Concurrency limiter returned by createLimiter.

interface Limiter {
schedule<T>(fn: (signal: AbortSignal) => PromiseLike<T> | T, options?: SignalOptions): Promise<T>;
readonly active: number;
readonly pending: number;
readonly concurrency: number;
onIdle(): Promise<void>;
clear(): void;
}

MapLimitOptions

interface MapLimitOptions extends SignalOptions {
readonly stopOnError?: boolean;
}

backoffDelay

Backoff delay for retry number attempt (1-based): min(maxDelay, minDelay * factor^(attempt-1)) with jitter.

function backoffDelay(attempt: number, options?: Pick<RetryOptions, 'minDelay' | 'maxDelay' | 'factor' | 'jitter' | 'random'>): number;

Example:

backoffDelay(3, { minDelay: 100, jitter: 'none' }); // => 400

poll

Calls fn repeatedly until until(value) holds, waiting interval ms between calls.

function poll<T>(fn: (signal: AbortSignal, attempt: number) => PromiseLike<T> | T, options?: PollOptions<T>): Promise<T>;

Example:

let calls = 0;
const job = await poll(async () => ({ status: ++calls < 3 ? 'running' : 'done' }), {
until: (j) => j.status !== 'running',
interval: 5,
timeout: 1_000,
});
[job.status, calls]; // => ['done', 3]

retry

Calls fn(attempt, signal) until it succeeds, retrying failures with exponential backoff and jitter. Never retries abort errors; stops immediately when signal aborts. Rejects with the last error (non-Error throws normalized by toError).

function retry<T>(fn: (attempt: number, signal: AbortSignal) => PromiseLike<T> | T, options?: RetryOptions): Promise<T>;

Example:

let calls = 0;
const data = await retry(
async (attempt) => {
calls = attempt;
if (attempt < 3) throw new Error('Service unavailable.');
return 'ok';
},
{ retries: 4, minDelay: 1, jitter: 'none', retryIf: (e) => e.message === 'Service unavailable.' },
);
data; // => 'ok'
calls; // => 3

timeout

Rejects with TimeoutError (code HLP_TIMEOUT) if work does not settle within ms. When work is a function it receives an AbortSignal that aborts on timeout or outer abort, so the inner operation is actually cancelled.

function timeout<T>(work: PromiseLike<T> | ((signal: AbortSignal) => PromiseLike<T> | T), ms: number, options?: TimeoutOptions): Promise<T>;

Example:

await timeout(Promise.resolve('ok'), 1_000); // => 'ok'
await timeout((signal) => sleep(10_000, { signal }), 20); // => throws HLP_TIMEOUT

PollOptions

interface PollOptions<T> extends SignalOptions {
readonly interval?: number;
readonly until?: (value: T, attempt: number) => boolean;
readonly timeout?: number;
}

RetryOptions

interface RetryOptions extends SignalOptions {
readonly retries?: number;
readonly minDelay?: number;
readonly maxDelay?: number;
readonly factor?: number;
readonly jitter?: 'full' | 'equal' | 'none';
readonly retryIf?: (error: Error, attempt: number) => boolean | PromiseLike<boolean>;
readonly delayFor?: (error: Error, attempt: number, computed: number) => number | undefined;
readonly onRetry?: (error: Error, attempt: number, delayMs: number) => void;
readonly random?: () => number;
}

TimeoutOptions

interface TimeoutOptions extends SignalOptions {
readonly message?: string;
}

abortReason

The error an aborted signal should reject with: its reason when that is a HelperError, TimeoutError for timeout reasons (AbortSignal.timeout), otherwise AbortError with the reason as cause.

function abortReason(signal: AbortSignal): HelperError;

Example:

const controller = new AbortController();
controller.abort();
abortReason(controller.signal).code; // => 'HLP_ABORTED'

anySignal

Signal that aborts when any input aborts (native AbortSignal.any when present; undefined entries are ignored).

function anySignal(signals: readonly (AbortSignal | undefined)[]): AbortSignal;

Example:

const user = new AbortController();
const signal = anySignal([user.signal, timeoutSignal(5_000)]);
user.abort();
signal.aborted; // => true

deferred

Creates a promise with external resolve/reject (native Promise.withResolvers when present).

function deferred<T>(): Deferred<T>;

Example:

const ready = deferred<string>();
setTimeout(() => ready.resolve('connected'), 0);
await ready.promise; // => 'connected'

raceSignal

Settles like promise, or rejects with AbortError as soon as signal aborts (the underlying work is not cancelled; pass the signal to it too). Removes its listener when settled.

function raceSignal<T>(promise: PromiseLike<T>, signal: AbortSignal | undefined): Promise<T>;

Example:

const controller = new AbortController();
const never = new Promise<string>(() => {});
const pending = raceSignal(never, controller.signal);
controller.abort();
await pending; // => throws HLP_ABORTED

sleep

Resolves after ms; rejects with AbortError (and clears the timer) when signal aborts.

function sleep(ms: number, options?: SignalOptions): Promise<void>;

Example:

await sleep(10);
const controller = new AbortController();
const pending = sleep(10_000, { signal: controller.signal });
controller.abort();
await pending; // => throws HLP_ABORTED

throwIfAborted

Throws abortReason(signal) when signal is aborted.

function throwIfAborted(signal: AbortSignal | undefined): void;

Example:

const controller = new AbortController();
throwIfAborted(controller.signal); // no-op while not aborted
controller.abort();
throwIfAborted(controller.signal); // => throws HLP_ABORTED

timeoutSignal

Signal that aborts after ms with a timeout reason (native AbortSignal.timeout when present).

function timeoutSignal(ms: number): AbortSignal;

Example:

const signal = timeoutSignal(10);
await sleep(1_000, { signal }); // => throws HLP_TIMEOUT

withCancel

Starts fn with a fresh AbortSignal and returns a handle to cancel it.

function withCancel<T>(fn: (signal: AbortSignal) => PromiseLike<T> | T): Cancellable<T>;

Example:

const search = withCancel((signal) => sleep(10_000, { signal }).then(() => ['Ada']));
search.cancel();
await search.promise; // => throws HLP_ABORTED
search.signal.aborted; // => true

MAX_DELAY_MS

Largest delay setTimeout honours (about 24.8 days); larger values would fire immediately.

const MAX_DELAY_MS = 2147483647;

Cancellable

A running operation that can be cancelled.

interface Cancellable<T> {
readonly promise: Promise<T>;
readonly cancel: (reason?: unknown) => void;
readonly signal: AbortSignal;
}

Deferred

{ promise, resolve, reject } for settling a promise from outside.

interface Deferred<T> {
readonly promise: Promise<T>;
readonly resolve: (value: T | PromiseLike<T>) => void;
readonly reject: (reason?: unknown) => void;
}

SignalOptions

Options accepted by every cancellable helper.

interface SignalOptions {
readonly signal?: AbortSignal | undefined;
}