Skip to content

Repository files navigation

reinsjs

Rein in your async. Structured concurrency and cancellation for JavaScript & TypeScript — scoped tasks, automatic cancellation, timeouts. No DSL, no generators. Just async/await.

CInpmlicense: MITzero deps

When one task fails, the rest auto-cancel. No AbortController plumbing. Six lines:

import{withScope}from"reinsjs";const[user,orders,prefs]=awaitwithScope(async(scope)=>{constuser=scope.spawn((signal)=>fetch(`/u/${id}`,{ signal }).then(r=>r.json()));constorders=scope.spawn((signal)=>fetch(`/o/${id}`,{ signal }).then(r=>r.json()));constprefs=scope.spawn((signal)=>fetch(`/p/${id}`,{ signal }).then(r=>r.json()));returnPromise.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 AbortSignal through every call, remember to check signal.aborted, wire up the abort listener 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 reinsjs

Zero 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,// scopessleep,withTimeout,retry,race,// helpersCancellationError,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.

interfaceScope{/** Aborted when the scope tears down. Pass to fetch/sleep/DB drivers. */readonlysignal: 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;}interfaceWithScopeOptions{signal?: AbortSignal;// external cancellation — when this aborts, the scope cancelstimeout?: number;// ms — cancel the whole scope after this longname?: string;// label for debugging / error messagesconcurrency?: 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.constpages=awaitwithScope((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});constuser=scope.spawn((s)=>fetch(`/u/${id}`,{signal: s}));constdata=awaituser;// ← 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:

constscope=createScope();try{awaitscope.spawn(work);}finally{awaitscope.dispose();}

sleep(ms, signal?)

A cancellable delay. Rejects promptly if signal aborts, so it unwinds cleanly inside a scope.

awaitwithScope(async(scope)=>{awaitsleep(1000,scope.signal);// rejects immediately if the scope cancels});

withTimeout(task, ms)

Sugar for one task with a deadline.

constdata=awaitwithTimeout((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.

constdata=awaitwithScope((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.constdata=awaitrace([(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{awaitwithScope(/* … */);}catch(err){if(isCancellation(err))return;// expected: user navigated away, etc.throwerr;// a real failure}

Semantics (the contract)

  1. Concurrency. Tasks spawned in a scope run concurrently.
  2. Join-on-exit.withScope doesn't resolve until all spawned tasks settle — even ones the body didn't await. No leaks.
  3. Error → cancel siblings. The first task (or the body) to throw becomes the cause: every other task is aborted, and withScope rejects with that root-cause error — not the secondary cancellation noise.
  4. Timeout.timeout aborts the whole scope and rejects with TimeoutError.
  5. External cancellation. If options.signal aborts, the scope cancels and rejects with a CancellationError carrying the original reason as .cause.
  6. Cooperative. Cancellation is delivered via AbortSignal. Tasks that forward the signal unwind promptly (see caveat below).
  7. Nestable. Scopes nest; pass outer.signal to an inner withScope and an outer cancel propagates inward.
  8. No unhandled rejections. Ignoring a spawn() result never produces an unhandledRejection — 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 signalscope.spawn((signal)=>fetch(url,{ signal }));scope.spawn((signal)=>sleep(1000,signal));// ❌ Cannot be cancelled — ignores the signal, runs to completionscope.spawn(()=>fetch(url));// no signal passedscope.spawn(()=>heavyCpuLoop());// never yields, never checks

Because 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.awaitrunInWorker((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(()=>{constcontroller=newAbortController();withScope(async(scope)=>{constdata=awaitscope.spawn((s)=>fetch(url,{signal: s}).then(r=>r.json()));setState(data);},{signal: controller.signal}).catch((err)=>{if(!isCancellation(err))throwerr;});return()=>controller.abort();// unmount → everything in the scope aborts},[url]);

Race: first one to finish wins, the rest get cancelled:

constwinner=awaitwithScope(async(scope)=>{returnPromise.race([scope.spawn((s)=>fetchFrom(mirrorA,s)),scope.spawn((s)=>fetchFrom(mirrorB,s)),]);});// scope exit aborts the loser

There'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

How it compares

OptionWhat it isWhy reinsjs instead
EffectFull effect-system / runtime with fibersReal structured concurrency, but you adopt a heavy embedded DSL and rewrite into it. reinsjs is a single primitive you drop into plain async/await.
EffectionStructured concurrency via generatorsBulletproof 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 AbortControllerThe platform primitiveGives you the pieces but no scope or lifetime management — you still thread signals and join children by hand. reinsjsis the missing scope.
p-limit / p-map / p-queueConcurrency limitersSolve 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 guaranteefinally 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

About

Rein in your async. Structured concurrency and cancellation for JS/TS — scoped tasks, automatic cancellation, timeouts. No DSL, no generators. Just async/await.

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages