reinsjs
v0.3.0
Published
Rein in your async. Structured concurrency and cancellation for JavaScript & TypeScript — scoped tasks, automatic cancellation, timeouts. No DSL, no generators. Just async/await.
Maintainers
Readme
reinsjs
Rein in your async. Structured concurrency and cancellation for JavaScript & TypeScript — scoped tasks, automatic cancellation, timeouts. No DSL, no generators. Just
async/await.
When one task fails, the rest auto-cancel. No AbortController plumbing. Six lines:
import { withScope } from "reinsjs";
const [user, orders, prefs] = await withScope(async (scope) => {
const user = scope.spawn((signal) => fetch(`/u/${id}`, { signal }).then(r => r.json()));
const orders = scope.spawn((signal) => fetch(`/o/${id}`, { signal }).then(r => r.json()));
const prefs = scope.spawn((signal) => fetch(`/p/${id}`, { signal }).then(r => r.json()));
return Promise.all([user, orders, prefs]);
}, { timeout: 5000 });
// Any fetch fails → the other two are aborted, withScope rejects with that error.
// 5s elapses → all three are aborted, rejects with TimeoutError.
// Nothing leaks. You never touched an AbortController.Why
JavaScript has no first-class way to manage the lifetime of concurrent async work:
- Cancellation is hand-rolled every time — thread an
AbortSignalthrough every call, remember to checksignal.aborted, wire up theabortlistener cleanup. Miss a spot, leak work. - Orphaned tasks are a standard bug class — fire off
doThing()without awaiting and it keeps running after its caller returned: racing, double-writing, throwing into the void. - Errors get lost — an unawaited rejection becomes an
unhandledRejection, or silently vanishes.
reinsjs gives you a scope (a.k.a. a nursery) that owns the tasks started inside it. When the scope exits, every task it started is guaranteed finished or cancelled. Think Go's context + Python Trio's nurseries + Kotlin's coroutineScope, but native-feeling in JS.
Install
npm install reinsjsZero runtime dependencies. ESM + CJS + types. ~1.2 kB brotlied (tree-shaken withScope). CI-tested on Node 18/20/22, Bun, and Deno; works in browsers.
The API
import {
withScope, createScope, // scopes
sleep, withTimeout, retry, race, // helpers
CancellationError, TimeoutError, isCancellation, // errors
} from "reinsjs";withScope(body, options?)
The core. Runs body(scope) and does not resolve until every task spawned in the scope has settled or been cancelled — even tasks the body never awaited.
interface Scope {
/** Aborted when the scope tears down. Pass to fetch/sleep/DB drivers. */
readonly signal: AbortSignal;
/** Start a child task bound to this scope. Returns a promise for its result. */
spawn<T>(task: (signal: AbortSignal) => Promise<T> | T): Promise<T>;
/** Spawn several at once and await all results, in order. */
spawnAll<T>(tasks: Iterable<(signal: AbortSignal) => Promise<T> | T>): Promise<T[]>;
/** Cancel the whole scope (and all children) now. */
cancel(reason?: unknown): void;
}
interface WithScopeOptions {
signal?: AbortSignal; // external cancellation — when this aborts, the scope cancels
timeout?: number; // ms — cancel the whole scope after this long
name?: string; // label for debugging / error messages
concurrency?: number; // max tasks running at once; extra spawns queue
}Bounded fan-out — cap how many run at once without losing the scope guarantees:
// Crawl 1000 URLs, 8 at a time; if any fails, the rest are cancelled.
const pages = await withScope(
(scope) => scope.spawnAll(urls.map((u) => (signal) => fetch(u, { signal }))),
{ concurrency: 8 },
);createScope(options?) — await using
A scope you manage yourself, for explicit resource management. Spawn into it; when the block exits, it cancels unfinished children, waits for them to unwind, and re-throws the first real failure.
await using scope = createScope({ timeout: 5000 });
const user = scope.spawn((s) => fetch(`/u/${id}`, { signal: s }));
const data = await user;
// ← block end: scope tears down. Nothing leaks, even on throw.On runtimes without await using (Node < 24 with no polyfill), use the explicit form — same guarantees:
const scope = createScope();
try {
await scope.spawn(work);
} finally {
await scope.dispose();
}sleep(ms, signal?)
A cancellable delay. Rejects promptly if signal aborts, so it unwinds cleanly inside a scope.
await withScope(async (scope) => {
await sleep(1000, scope.signal); // rejects immediately if the scope cancels
});withTimeout(task, ms)
Sugar for one task with a deadline.
const data = await withTimeout((signal) => fetch(url, { signal }), 1000);retry(task, options?)
Run a task, retrying on failure with exponential backoff. Cancellations are never retried (a CancellationError/AbortError or an aborted signal stops it immediately), and the backoff delay is cancellable — so a retry loop unwinds promptly inside a scope.
const data = await withScope((scope) =>
retry((s) => fetch(url, { signal: s }).then((r) => r.json()), {
attempts: 5, // total tries (default 3)
delay: 200, // base ms (default 100)
factor: 2, // backoff multiplier (default 2)
maxDelay: 5000, // cap (default ∞)
jitter: true, // randomize delay (default false)
signal: scope.signal,
}),
);race(tasks, options?)
Run tasks concurrently and resolve with the first one to succeed, cancelling the rest. Unlike Promise.race (which settles on the first task to finish — including a failure), race ignores individual rejections: a failing task drops out and the race continues. Only if every task fails does it reject, with an AggregateError. The losers are aborted the moment a winner appears, and race waits for them to unwind before resolving.
// Fastest mirror wins; the slower request is cancelled. A 5s deadline applies.
const data = await race(
[
(signal) => fetch(mirrorA, { signal }).then((r) => r.json()),
(signal) => fetch(mirrorB, { signal }).then((r) => r.json()),
],
{ timeout: 5000 },
);This is the natural complement to spawnAll/Promise.all ("all must succeed") — race is "first success wins."
CancellationError / TimeoutError / isCancellation(err)
Typed errors, plus a helper to tell "was cancelled" from "actually failed":
try {
await withScope(/* … */);
} catch (err) {
if (isCancellation(err)) return; // expected: user navigated away, etc.
throw err; // a real failure
}Semantics (the contract)
- Concurrency. Tasks spawned in a scope run concurrently.
- Join-on-exit.
withScopedoesn't resolve until all spawned tasks settle — even ones the body didn'tawait. No leaks. - Error → cancel siblings. The first task (or the body) to throw becomes the cause: every other task is aborted, and
withScoperejects with that root-cause error — not the secondary cancellation noise. - Timeout.
timeoutaborts the whole scope and rejects withTimeoutError. - External cancellation. If
options.signalaborts, the scope cancels and rejects with aCancellationErrorcarrying the original reason as.cause. - Cooperative. Cancellation is delivered via
AbortSignal. Tasks that forward the signal unwind promptly (see caveat below). - Nestable. Scopes nest; pass
outer.signalto an innerwithScopeand an outer cancel propagates inward. - No unhandled rejections. Ignoring a
spawn()result never produces anunhandledRejection— the error surfaces through the scope instead.
⚠️ Cooperative cancellation (read this)
JavaScript cannot forcibly kill a running async function — there are no green threads to interrupt. reinsjs cancels by aborting an AbortSignal. A task unwinds promptly only if it forwards that signal to the things it awaits (fetch, sleep, DB drivers, child scopes) or checks signal.aborted itself.
// ✅ Cancels promptly — forwards the signal
scope.spawn((signal) => fetch(url, { signal }));
scope.spawn((signal) => sleep(1000, signal));
// ❌ Cannot be cancelled — ignores the signal, runs to completion
scope.spawn(() => fetch(url)); // no signal passed
scope.spawn(() => heavyCpuLoop()); // never yields, never checksBecause of join-on-exit, an uncooperative task delays the scope's teardown until it finishes on its own. This is a JavaScript limitation, not a reinsjs bug — we surface it loudly so there are no surprises.
The escape hatch: reinsjs/worker (experimental)
For the one case cooperative cancellation can't handle — a CPU-bound task that never checks the signal — there's a genuinely preemptive option: run it in a worker thread and terminate() it.
import { runInWorker } from "reinsjs/worker";
// This loop never checks any signal. It is still killed at 500ms.
await runInWorker((n) => { while (true) heavyCompute(n); }, 1_000_000, { timeout: 500 });
// → rejects with TimeoutError, and the thread is actually terminated.The trade-off is the worker boundary: the task runs in a fresh thread, so it can't close over outer variables, and its input/result must be structured-cloneable — pass everything it needs via input. Node-only for now (node:worker_threads); browser/Deno support is on the roadmap. This is the only way to get true preemption in JS — see docs/cancellation-rfc.md for the full analysis and where the language could go next.
Recipes
Cancel other requests when the first one fails — that's the quickstart above.
Cancel on navigate (React):
useEffect(() => {
const controller = new AbortController();
withScope(async (scope) => {
const data = await scope.spawn((s) => fetch(url, { signal: s }).then(r => r.json()));
setState(data);
}, { signal: controller.signal }).catch((err) => {
if (!isCancellation(err)) throw err;
});
return () => controller.abort(); // unmount → everything in the scope aborts
}, [url]);Race: first one to finish wins, the rest get cancelled:
const winner = await withScope(async (scope) => {
return Promise.race([
scope.spawn((s) => fetchFrom(mirrorA, s)),
scope.spawn((s) => fetchFrom(mirrorB, s)),
]);
}); // scope exit aborts the loserThere's a runnable fan-out demo in examples/fanout.ts:
npx tsx examples/fanout.ts # happy path
npx tsx examples/fanout.ts --fail # one fails → the others auto-cancelHow it compares
| Option | What it is | Why reinsjs instead |
|--------|-----------|-------------------|
| Effect | Full effect-system / runtime with fibers | Real structured concurrency, but you adopt a heavy embedded DSL and rewrite into it. reinsjs is a single primitive you drop into plain async/await. |
| Effection | Structured concurrency via generators | Bulletproof teardown — but it's built on function* / yield*, a different mental model from async/await. reinsjs keeps you in async/await (see the honest trade-off below). |
| raw AbortController | The platform primitive | Gives you the pieces but no scope or lifetime management — you still thread signals and join children by hand. reinsjs is the missing scope. |
| p-limit / p-map / p-queue | Concurrency limiters | Solve how many at once. reinsjs does that too ({ concurrency }) and owns task lifetime + cancellation. |
reinsjs is the lightweight, async/await-native primitive in the gap between "raw AbortSignal" and "adopt Effect."
Honest trade-off vs. Effection
Effection drives generators, so it can inject teardown at every yield point and guarantee finally blocks run even on abort. reinsjs uses plain async/await + cooperative AbortSignal, which is why a task that ignores the signal can't be force-unwound (see the caveat above; the reinsjs/worker escape hatch covers the CPU-bound case). The deal: reinsjs trades Effection's strongest teardown guarantee for zero new syntax and a ~1 kB footprint. If you want airtight teardown and don't mind yield*, use Effection. If you want structured concurrency that disappears into the async/await you already write, use reinsjs.
Performance
Overhead is ~250 ns per spawned task over a raw Promise.all (run npm run bench). That's negligible next to any real async work (a network call or disk read is ~10,000× that), though measurable if you're fanning out thousands of near-empty promises in a hot loop. The structured guarantees — join-on-exit, cancel-on-error, no leaks — are what that buys you.
License
MIT © webcoderspeed
