@absoluteui/helpers/async
Abortable sleep, timeouts, retry with backoff, concurrency limits, batching, and polling.
import { ... } from '@absoluteui/helpers/async';How to use
Section titled “How to use”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; // => 1peak; // => 2Use 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 answersconst 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_TIMEOUTHow 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; // => 2How 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']What goes wrong?
Section titled “What goes wrong?”raceSignaland a plain promise passed totimeoutstop waiting but do not stop the work; pass a function totimeout, 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, becausesetTimeoutwould 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_ABORTEDawait sleep(MAX_DELAY_MS + 1); // => throws HLP_ASYNC_INVALID_MSAPI reference
Section titled “API reference”Functions
Section titled “Functions”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 keysdebounceAsync
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 resultlatest; // => ['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; // => 1toAsyncIterable
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']Interfaces
Section titled “Interfaces”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;}Concurrency
Section titled “Concurrency”Functions
Section titled “Functions”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; // => 2createSerialQueue
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 finishesforEachAsync
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; // => 1partitionSettled
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]Interfaces
Section titled “Interfaces”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;}Control
Section titled “Control”Functions
Section titled “Functions”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' }); // => 400poll
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; // => 3timeout
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_TIMEOUTInterfaces
Section titled “Interfaces”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;}Signal
Section titled “Signal”Functions
Section titled “Functions”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; // => truedeferred
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_ABORTEDsleep
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_ABORTEDthrowIfAborted
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 abortedcontroller.abort();throwIfAborted(controller.signal); // => throws HLP_ABORTEDtimeoutSignal
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_TIMEOUTwithCancel
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_ABORTEDsearch.signal.aborted; // => trueConstants
Section titled “Constants”MAX_DELAY_MS
Largest delay setTimeout honours (about 24.8 days); larger values would fire immediately.
const MAX_DELAY_MS = 2147483647;Interfaces
Section titled “Interfaces”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;}