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.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.
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.
import {
withScope, createScope, // scopes
sleep, withTimeout, retry, race, // helpers
CancellationError, TimeoutError, isCancellation, // errors
} from "reinsjs";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 },
);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();
}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
});Sugar for one task with a deadline.
const data = await withTimeout((signal) => fetch(url, { signal }), 1000);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,
}),
);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."
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
}- 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.
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.
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.
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-cancel| 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."
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.
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.
MIT © webcoderspeed