npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

asyncraft

v0.2.0

Published

Zero-dependency async control-flow utilities: retry, timeout, circuit breaker, concurrency limit, async map, single-flight memoize, debounce, deferred, and AbortSignal helpers — fully typed, AbortSignal-aware.

Readme

asyncraft

npm version CI npm downloads bundle size types license

Zero-dependency async control-flow utilities: retry, timeout, circuit breaker, concurrency limit, async map, single-flight memoize, debounce, deferred, and AbortSignal helpers — fully typed, AbortSignal-aware.

Instead of installing p-retry + p-timeout + p-limit + p-map + p-memoize + a circuit-breaker lib separately, get the resilience and concurrency primitives every project eventually needs in one tiny, tree-shakeable package.

  • Zero dependencies — nothing else lands in your node_modules.
  • TypeScript-first — strict types, no any, full inference.
  • AbortSignal everywhere — every wait is cancellable.
  • Composable — stack circuitBreakerretrywithTimeout inside asyncMap.
  • AI-friendly — full TSDoc in IntelliSense + a machine-readable llms.txt so coding agents pick the right primitive.
  • ESM + CJS — works in modern and legacy setups, Node ≥ 18.

Which primitive do I need?

| Problem | Use | | -------------------------------------------------------- | ---------------- | | Fails intermittently — retry with backoff | retry | | Might hang — enforce a time budget | withTimeout | | Dependency is down — fail fast, stop hammering it | circuitBreaker | | Too much parallelism — cap it | createLimit | | Run over a list with bounded parallelism, keep order | asyncMap | | Same call fired repeatedly/concurrently — dedupe + cache | memoize | | Collapse rapid triggers into one trailing call | debounceAsync | | A promise you resolve from elsewhere | deferred | | Merge several cancellation sources | anySignal | | Just wait, cancellably | sleep |

Install

npm install asyncraft

Usage

retry(fn, options?)

Call a function until it succeeds, with exponential backoff and jitter.

import { retry } from 'asyncraft';

const user = await retry(() => fetchUser(id), {
  retries: 5, // extra attempts after the first (default 3)
  minDelay: 100, // ms before first retry (default 100)
  maxDelay: 10_000, // cap for any single delay (default 10s)
  factor: 2, // exponential growth (default 2)
  jitter: true, // randomize delays to avoid thundering herds (default true)
  shouldRetry: (err) => err instanceof HttpError && err.status >= 500,
  onRetry: (err, attempt, delay) => console.warn(`attempt ${attempt} failed, waiting ${delay}ms`),
});

When every attempt fails, a RetryError is thrown with the last error as .cause and the attempt count as .attempts. Pass a signal to abort retrying (including mid-delay).

withTimeout(promiseOrFn, ms, options?)

Reject with TimeoutError if something takes too long. Pass a function to receive an AbortSignal that fires on timeout, so the underlying work is actually cancelled:

import { withTimeout, TimeoutError } from 'asyncraft';

// simple: race a promise against the clock
const data = await withTimeout(loadData(), 5000);

// better: cancel the underlying work too
const res = await withTimeout((signal) => fetch(url, { signal }), 5000);

createLimit(concurrency)

A minimal p-limit: run at most N promises at once.

import { createLimit } from 'asyncraft';

const limit = createLimit(4);
const pages = await Promise.all(urls.map((url) => limit(() => fetch(url))));

limit.activeCount; // running now
limit.pendingCount; // waiting in queue
limit.clearQueue(); // drop everything not yet started

asyncMap(items, mapper, options?)

Map over an iterable with bounded concurrency. Results always come back in input order.

import { asyncMap } from 'asyncraft';

const pages = await asyncMap(urls, (url) => fetch(url), { concurrency: 4 });

// don't fail fast — collect errors per item, like Promise.allSettled
const results = await asyncMap(urls, (url) => fetch(url), { concurrency: 4, settled: true });
for (const r of results) {
  if (r.status === 'fulfilled') use(r.value);
  else report(r.reason);
}

circuitBreaker(fn, options?)

Fail fast while a dependency is down. After failureThreshold consecutive failures the circuit opens and calls reject immediately with CircuitOpenError (without calling fn); after resetTimeout a single trial call is allowed, and success closes it again.

import { circuitBreaker, CircuitOpenError } from 'asyncraft';

const call = circuitBreaker((id: string) => api.fetchUser(id), {
  failureThreshold: 3,
  resetTimeout: 10_000,
  shouldTrip: (err) => !(err instanceof HttpError && err.status < 500), // ignore 4xx
});

try {
  const user = await call('123');
} catch (err) {
  if (err instanceof CircuitOpenError) return cachedUser; // fast fallback
  throw err;
}

call.state; // 'closed' | 'open' | 'half-open'
call.reset(); // force back to closed

memoize(fn, options?)

De-duplicate and cache async calls. Concurrent identical calls share one in-flight promise (single-flight); resolved values are cached until ttl. Turns a thundering herd of identical requests into one call.

import { memoize } from 'asyncraft';

const getUser = memoize((id: string) => api.fetchUser(id), { ttl: 60_000 });

// 10 concurrent getUser('42') → one fetch, one shared result
const [a, b] = await Promise.all([getUser('42'), getUser('42')]);

getUser.delete('42'); // evict one key
getUser.clear(); // evict everything

Options: ttl (default Infinity), key (default JSON.stringify(args)), cacheRejections (default false), maxSize (LRU cap, default Infinity).

debounceAsync(fn, { wait })

Trailing-edge debounce: rapid calls collapse into a single invocation wait ms after the last call, using the latest args. Every caller in the window receives that invocation's result.

import { debounceAsync } from 'asyncraft';

const save = debounceAsync((doc: Doc) => api.save(doc), { wait: 500 });
editor.on('change', (doc) => save(doc)); // one save 500ms after typing stops

save.cancel(); // reject anything pending
save.pending; // is a call scheduled?

deferred()

A promise you resolve or reject from the outside — no hand-rolled executor.

import { deferred } from 'asyncraft';

const ready = deferred<void>();
server.once('listening', () => ready.resolve());
server.once('error', (err) => ready.reject(err));
await ready.promise;

sleep(ms, options?)

A cancellable delay.

import { sleep } from 'asyncraft';

await sleep(1000);
await sleep(60_000, { signal: controller.signal }); // rejects on abort

anySignal(...signals) & timeoutSignal(ms)

Compose cancellation. timeoutSignal makes a signal that self-aborts after ms; anySignal merges several signals into one that aborts when the first does (a drop-in for AbortSignal.any on Node < 20).

import { anySignal, timeoutSignal } from 'asyncraft';

// abort when the caller cancels OR a 5s deadline passes
const signal = anySignal(req.signal, timeoutSignal(5000));
await fetch(url, { signal });

Composing

The pieces are designed to combine:

import { asyncMap, retry, withTimeout } from 'asyncraft';

const results = await asyncMap(
  urls,
  (url) =>
    retry(() => withTimeout((signal) => fetch(url, { signal }), 5000), {
      retries: 3,
    }),
  { concurrency: 8, settled: true },
);

API summary

| Export | Purpose | | --------------------------- | ------------------------------------------------ | | retry(fn, opts?) | Exponential-backoff retry with jitter | | withTimeout(p, ms, o?) | Time-bound a promise, cancel underlying work | | circuitBreaker(fn, opts?) | Fail fast while a dependency is down | | createLimit(n) | Concurrency limiter (p-limit style) | | asyncMap(items, fn, o?) | Ordered async map with bounded concurrency | | memoize(fn, opts?) | Single-flight de-dup + TTL cache for async calls | | debounceAsync(fn, {wait}) | Trailing-edge async debounce | | deferred() | Externally-resolvable promise | | sleep(ms, opts?) | Cancellable delay | | anySignal(...signals) | Merge AbortSignals into one | | timeoutSignal(ms) | An AbortSignal that self-aborts after ms | | TimeoutError | Thrown by withTimeout | | RetryError | Thrown by retry when attempts are exhausted | | CircuitOpenError | Thrown by an open circuitBreaker |

For AI coding agents

asyncraft is built to be consumed correctly by LLM-based tools:

  • A machine-readable llms.txt ships in the published package — a concise map of every export with signatures, a "which primitive for which problem" guide, conventions, and copy-paste recipes, so an agent can pick the right primitive without reading source.
  • Every public symbol carries full TSDoc (@param / @throws / @remarks / @example), so the same guidance appears in editor IntelliSense and in any tool that reads .d.ts.
  • Strong, inference-friendly types (verified by type-level tests) mean generated call sites either type-check or fail loudly.

Stability guarantees

These behaviors are pinned by a dedicated test suite (unit + stress + property-based + type-level; see TESTING.md) — a change that breaks any of them fails CI and cannot be published:

  • No timer or listener leaks. Every setTimeout is cleared and every AbortSignal listener removed once an operation settles or is aborted. A long-lived signal can be reused across thousands of calls, and a cancelled delay never keeps the process alive.
  • Abort reasons are always throwable. If signal.reason is an Error it is rethrown as-is; strings and other primitives are wrapped in an Error named AbortError with a readable message.
  • Deterministic retry accounting. retry calls your function exactly retries + 1 times in the worst case. Without jitter, delays follow min(minDelay · factor^(n-1), maxDelay) and never decrease; with jitter each delay stays within [50%, 100%] of that value.
  • Order preservation. asyncMap results always match input order, for any input and any concurrency (verified with property-based tests).
  • Failure isolation. A rejecting task frees its createLimit slot like any other, and asyncMap's settled mode reports every item — one failure never stalls the queue or hides another result.
  • Typed error surface. Timeouts throw TimeoutError, exhausted retries throw RetryError (with .cause and .attempts) — both instanceof-able.

All public APIs carry full TSDoc (@param, @throws, @remarks, @example), so the same documentation appears in your editor's IntelliSense.

Development

npm install
npm run tdd           # TDD loop: vitest watch mode + live coverage
npm test              # unit + property + type tests
npm run test:coverage # same, plus the coverage gate CI enforces
npm run lint          # eslint (type-checked)
npm run typecheck     # tsc --noEmit
npm run build         # tsup → dist/ (ESM + CJS + d.ts)

See TESTING.md for the TDD workflow and the test-harness layers (unit / stability / property-based / type tests).

License

MIT