taskloom
v1.0.0
Published
Task-based structured concurrency primitives for Node.js and browser
Maintainers
Readme
Taskloom
Structured concurrency for Node.js and browser - run parallel async work that cancels cleanly, leaves no orphan tasks, and behaves the way you expect.
- Zero dependencies · Node 22+ and browser · ESM only
- Scopes and tasks · First failure or first result cancels the rest; background work is scope-bound or explicitly detached
- Built on
AbortSignal· Your async work can respectsignal.abortedand exit early; cancellation can carry a reason
Table of contents
- Taskloom
- Table of contents
- Install
- Quick start
- Why Taskloom?
- Core concepts
- Primitives
- sync - "All must succeed; first failure cancels the rest"
- race - "First result wins; cancel the rest"
- rush - "First result back, wait for all (no orphans)"
- branch - "Background work in a scope; cancel when scope closes"
- spawn - "Run callback in a scope; or fire-and-forget with spawn.task"
- spawnScope - "Group fire-and-forget tasks under one scope"
- Zero-friction API
- Task naming
- Opinionated helpers
- Cancellation and cleanup
- Low-level API
- Debug and observability
- Strict mode
- Strict cancellation
- API reference
- Examples
- Requirements
- Contributing
Install
npm install taskloomRequirements: Node.js 22+ or browser; ESM ("type": "module" or .mjs). No runtime dependencies. The package supports both Node and browser via the same entry; bundlers (Vite, webpack, etc.) can resolve it for browser builds.
Quick start
Run two fetches in parallel; if one fails, the other is canceled and you get a single error.
import { sync } from "taskloom";
const [user, posts] = await sync(async ({ task }) => {
const u = task((signal) => fetch("/api/user", { signal }).then((r) => r.json()));
const p = task((signal) => fetch("/api/posts", { signal }).then((r) => r.json()));
return await task.all([u, p]);
});Why Taskloom?
| Problem | Taskloom solves it |
|--------|----------------------|
| One failure in parallel work | First failure aborts the scope → other tasks are canceled, not left running. |
| Promise.race losers keep running | race() aborts the scope → only the winner completes; you don't pay for work you ignore. |
| "First result" but other work must finish | rush() returns the first result, then the scope waits for all tasks so nothing is orphaned. |
| Background work with no cleanup | branch() and spawn() tie work to a scope or return a Task - clear boundaries, optional cancellation. |
| Cancellation | Built on AbortSignal; your async work can respect signal.aborted and exit early; scope.abort(reason) and onCancel(reason) pass a reason. |
Core concepts
- Scope - Owns an
AbortController. Tasks created with that scope’ssignalare canceled when the scope is closed (e.g. whenrunInScopeor a primitive exits). You can callscope.abort(reason)to cancel all tasks in the scope; the reason is available toonCancelhandlers. - Task - An async computation with a lifecycle: running → completed, failed, or canceled. It is awaitable (you can
await task) but is not a Promise. It is created withrunTask(work, options)or via primitives (task(work),task(name, work)). Each task receives anAbortSignal; when that signal is aborted, the task transitions to canceled and any registeredonCancelhandlers run before the await rejects. - Structured concurrency - Work runs inside scopes. When a scope closes (success or failure), all scope-bound tasks are canceled. No orphan tasks unless you explicitly use
spawn, which is not scope-bound.
Primitives
High-level functions that create a scope and run your callback with a way to start tasks. All primitives (except spawn / spawnScope) are scope-bound: when the primitive’s scope closes, every task started inside it is canceled.
sync - "All must succeed; first failure cancels the rest"
Runs all tasks concurrently and resolves only when every task has completed successfully. If any task rejects, the scope is aborted and the others are canceled; sync rejects with the first failure.
Before (plain promises): One failure → others keep running; no cleanup.
const [a, b] = await Promise.all([
fetch("/api/a").then((r) => r.json()),
fetch("/api/b").then((r) => r.json()),
]);After (Taskloom): First failure aborts the scope and cancels the other tasks.
import { sync } from "taskloom";
const [a, b] = await sync(async ({ task }) => {
const t1 = task((signal) => fetch("/api/a", { signal }).then((r) => r.json()));
const t2 = task((signal) => fetch("/api/b", { signal }).then((r) => r.json()));
return await task.all([t1, t2]);
});race - "First result wins; cancel the rest"
Resolves or rejects with the first task to settle. As soon as one task settles, the scope is aborted so every other task is canceled. The callback must start at least one task (via task(work) or task(name, work)); otherwise race throws.
Before: Promise.race returns the first result, but the "losers" keep running.
After: First result wins; all other tasks are canceled.
import { race } from "taskloom";
const first = await race(async ({ task }) => {
task((signal) => fetch("/api/fast", { signal }).then((r) => r.json()));
task((signal) => fetch("/api/slow", { signal }).then((r) => r.json()));
});rush - "First result back, wait for all (no orphans)"
Returns as soon as the first task settles, but the scope stays open until every started task has settled. Other tasks are not canceled; you get the first result and no orphan work. The callback must start at least one task (via task(work) or task(name, work)); otherwise rush throws.
import { rush } from "taskloom";
const first = await rush(async ({ task }) => {
task((signal) => fetch("/api/a", { signal }).then((r) => r.json()));
task((signal) => fetch("/api/b", { signal }).then((r) => r.json()));
});branch - "Background work in a scope; cancel when scope closes"
Use inside runInScope (or inside another primitive). Starts tasks and returns immediately; the code after branch(...) runs in parallel with the branch body. When the enclosing scope completes (e.g. when the runInScope callback settles), the branch scope is closed and any still-running tasks started in the branch are canceled.
import { branch, runInScope } from "taskloom";
await runInScope(async () => {
branch(async ({ task }) => {
task(() => fetch("/api/log"));
task(() => sendAnalytics());
});
// Runs immediately, in parallel with branch tasks
await doOtherWork();
});
// Scope closed → branch tasks are canceled if still runningspawn - "Run callback in a scope; or fire-and-forget with spawn.task"
spawn(callback) runs the callback with a TaskloomContext ({ task, scope }) in a new scope. The scope is linked to the parent when called from inside runInScope or another primitive, so parent abort cancels the spawn. Returns a Task<R> for the callback result.
spawn.task(work) runs a single async work function without attaching it to the current scope. The call returns a Task immediately; the work runs independently. The task is not canceled when the caller’s scope closes. Callable from sync or async code.
import { spawn } from "taskloom";
// Callback form: same shape as sync/race; scope linked to parent when in scope
const result = await spawn(async ({ task }) => {
const a = await task(async () => 1);
const b = await task(async () => 2);
return a + b;
});
// result === 3
// Fire-and-forget: no scope linkage
const task = spawn.task(() => fetch("/api/notify").then((r) => r.json()));
// Optional: await task later, or let it run to completion on its ownspawnScope - "Group fire-and-forget tasks under one scope"
Creates a scope for fire-and-forget work. The callback receives TaskloomContext ({ task, scope }); use task(work) to start tasks. Returns when the callback settles; does not wait for the spawned tasks.
import { spawnScope } from "taskloom";
await spawnScope(async ({ task }) => {
task(() => notifyServiceA());
task(() => notifyServiceB());
});
// Callback has settled; spawned tasks continue independentlyTask naming
For logging and debugging you can name tasks:
task(work)- one argument: the work function.task(name, work)- string name first, then work.task(work, { name: "..." })- work first, then options with optionalname.
The name is used in errors, strict-cancellation warnings, and task-tree introspection when debug is enabled. Task behavior (lifecycle, cancellation, result) is unchanged; only observability differs.
Task combinators
On the context task object you get Promise-like combinators for already-started tasks:
task.all(tasks)- Resolves with an array of results in order when all tasks fulfill; rejects with the first rejection.task.race(tasks)- Resolves or rejects with the first settlement.task.allSettled(tasks)- Resolves with an array of settlement results (fulfilled/rejected) in order.
Use them when you have multiple Task values (e.g. from task(work)) and want to await them together or take the first result.
Opinionated helpers
Inside primitives you get a task object that includes:
task.sleep(ms)- Promise that resolves aftermsms, or rejects if the scope’s signal is aborted first. Scope-bound; no timer leak on cancel.task.timeout(ms, work)- Runswork(signal)with a time limit. The work function receives the scope's AbortSignal so you can pass it tofetchor other cancelable APIs. If the limit elapses first, the scope is aborted and the Promise rejects with a timeout error.task.retry(fn, options)- Invokesfn(signal)on each attempt; receives the scope's AbortSignal. On failure retries with configurableretriesandbackoff('fixed'or'exponential'). If the scope is aborted, retry stops and the Promise rejects.task.limit(concurrency, options?)- Returns a limiter function that runs at mostconcurrencywork functions at a time. Use for batch API calls or I/O to avoid unbounded concurrency. Call the returned function withwork(signal) => Promise<T>; work receives the scope's AbortSignal. When the scope aborts, queued work is rejected ifcancelQueuedOnAbortis true (default). Options:{ cancelQueuedOnAbort?: boolean }. You can also usecreateLimiter(signal, options)directly with a scope signal andLimiterOptions(concurrency required).
These are available wherever you receive TaskloomContext (e.g. inside sync, race, rush, branch, spawn). Options for retry are typed (e.g. RetryOptions, RetryBackoff); the limiter uses LimiterOptions. All are exported from the package.
Cancellation and cleanup
- Scope:
scope.abort(reason?: unknown)aborts the scope’s signal so all tasks using that signal are canceled. The optionalreasonis available assignal.reasonand is passed toonCancelhandlers. - Task:
task.onCancel(handler: (reason?: unknown) => void)registers a handler that runs when the task is canceled (e.g. scope closed or primitive aborted). Handlers run before the task’sawaitrejects. If the task is already canceled when you register, the handler is invoked immediately with the cancellation reason. Use the reason to branch (e.g. timeout vs request-aborted).
Your async work receives an AbortSignal; check signal.aborted or use signal.reason and exit early to cooperate with cancellation.
Low-level API
runInScope(callback)- Creates a scope, invokescallback(scope), and closes the scope when the callback settles (fulfill or reject). All tasks created withscope.signalare canceled when the scope closes.runTask(work, options?)- Creates and runs a Task fromwork(signal) => Promise<T>. Options:{ signal?: AbortSignal, name?: string }. Whensignalis provided (e.g.scope.signal), the task is canceled when that signal aborts. Returns an awaitableTask<T>.
Use these when you need explicit scope boundaries or to run a single task with a parent signal.
Debug and observability
enableTaskDebug()- Enables collection of the live scope and task tree for subsequent execution. When disabled, no extra allocation or work (zero cost in production). Process-wide; Node 22+ built-ins only.subscribeTaskDebug(callback)- Registers a subscriber for realtime debug events (scope opened/closed, task registered/updated). Returns an unsubscribe function. When debug is enabled, the callback is invoked synchronously with event payloads. Subscriber throws are caught and logged so they don’t break the core.
When debug is enabled, the task tree can show scope IDs, task IDs, optional names, and status. With a subscriber, you can build live visualizations or logs.
Strict mode
enableStrictMode(options?)- Opt-in strict concurrency checks. When enabled, the library throwsStrictModeErrorwhen it detects:- Unstructured async - Async work started outside any Taskloom scope (e.g. not under
runInScopeor a primitive). - Ignored cancellation - A task is canceled but had no observable cancellation handling (e.g. no
onCancel, signal not passed). - Orphan tasks - A scope exits while a task created under that scope is still running.
- Branch without parent -
branch()is used without a parent scope (e.g. not insiderunInScopeor another primitive).
- Unstructured async - Async work started outside any Taskloom scope (e.g. not under
When strict mode is off, no checks run and behavior is unchanged. Options (e.g. StrictModeOptions) can provide onWarn(message) to be called with the violation message before the throw (e.g. for logging or tests). Import StrictModeError from "taskloom" to catch or assert on it.
Strict cancellation
withStrictCancellation(callback, options?)- Runs the callback in a scope (same shape asrunInScope). In development (e.g.NODE_ENV !== 'production'), after the scope is aborted, if any task started under that scope is still running after a configurable threshold (e.g. 2 seconds), the library emits a one-time warning per task (including task name and duration). In production, no checks and no extra overhead; behavior matchesrunInScope. Use to teach correct cancellation without changing production behavior.
API reference
Primitives: sync, race, rush, branch, spawn, spawnScope
Scope and task: runInScope, runTask, Scope, Task, TaskStatus, RunTaskOptions
Context and callback types: TaskloomContext, SyncCallback, TaskOptions, UnwrapTasks, SettledTasks
Debug: enableTaskDebug, subscribeTaskDebug, TaskDebugEvent
Strict: enableStrictMode, StrictModeError, StrictModeOptions, withStrictCancellation, StrictCancellationOptions
Helpers (types and factory): RetryOptions, RetryBackoff, LimiterOptions, createLimiter
All of the above are exported from the package entry; import from "taskloom" only. Internal helpers are not re-exported. Public symbols are documented with JSDoc at their declaration site so IDE tooltips show behavior and semantics.
Examples
Runnable examples and a short get-started guide live in examples/. Prerequisites: Node 22+, no extra deps. After npm run build, run e.g.:
node examples/sync-basic.mjs
node examples/race-basic.mjs
node examples/limit-batch.mjs
node examples/debug-mode.mjsFrom the repo root. See examples/README.md for the full list (sync, race, rush, branch, spawn, limit-batch, scope-cancel, nested-primitives, debug-mode, strict-mode).
Requirements
- Node.js 22+ or browser - the same package works in both; async context uses native
AsyncLocalStoragein Node and an in-library ponyfill in the browser. - ESM - package is
"type": "module"; useimportfrom"taskloom". - Zero runtime dependencies - only
devDependenciesfor build and tests.
Contributing
npm install
npm run build
npm run testTo remove build output: npm run clean.
Tests live under test/ and are not published. The published package contains only the dist/ output and package metadata.
