Skip to content

cancjs/canc

Repository files navigation

canc ⮿ a crafty foundation for cancelable promises

License PRs Welcome

Cancelable promise ecosystem based on native Promise: coroutines, async iterators, decorators, utilities, third-party library helpers.


Features

  • cancelable promise implementation built on top of ES Promise
  • generator-based cancelable replacements for async..await and async iterators
  • lazily evaluated cancelable promises
  • cancelable Fetch API
  • utility toolbox (delay, timeout, etc)
  • library helpers (Axios, Bluebird, RxJS, etc)
  • decorators for TypeScript and Babel
  • base packages to be used with custom promise implementation
  • UMD and ESM builds for modern and legacy browsers and Node.js
  • TypeScript-ready

Packages

Cancelable Promises

Cancellation-aware promise utilities:

Name Version Description
@cancjs/promise Version Cancelable promise implementation based on ES Promise
@cancjs/coroutine Version Cancelable generator-based drop-in replacements for async..await and async iterators
@cancjs/fetch Version Cross-platform Fetch API that uses cancelable promises
@cancjs/lazy-promise Version Cancelable lazily evaluated promise-like class
@cancjs/toolbox Version A collection of cancellation-aware promise helper functions and ponyfills

Native Promises

General-purpose promise utilities that use built-in Promise as promise implementation where applicable:

Package Version Description
@cancjs/coroutine-native Version Generator-based drop-in replacements for async..await and async iterators
@cancjs/lazy-promise-native Version Lazily evaluated promise-like class
@cancjs/toolbox Version A collection of promise helper functions

How It Works

Cancellation is a special form of promise rejection with cancel error that triggers registered handlers for the entire cancellation-aware promise chain.

canc promises implement two-way cancellation mechanism that treats promise chains as subscriptions:

  • cancellation propagates down the promise chain when parent promise is canceled

  • cancellation bubbles up the chain when all child promises are canceled and parent promise value is no longer consumed

Cancellation bubbling can be explicitly disabled on parent promise in case a promise causes side effects that shouldn't be implicitly discarded.

Two-way cancellation mechanism is supported for all common ways to establish a promise chain, including all, race, etc composition methods and coroutine yield.

A chain is cancelable only if it consists of canc promises. This requires to use cancellation-aware wrappers for Fetch API and third-party librararies, async and async* need to be replaced with cancelable generator-based coroutines.

Motivation

Promise cancellation is highly beneficial in real life scenarios yet it's not a part of existing ECMAScript specification. JavaScript API like Fetch AbortController use their own mechanisms that aren't unified with native promises.

A situation that is common in modern JavaScript applications is that a process like network request that stands behind long-running asynchronous task is abortable, consumers need to unsubscribe from results and abort initial process when it's no longer needed. This eventually becomes harder with uncancelable promises when a task is composed of smaller independent tasks.

See examples for more use cases.

Background

  • No official solution. Native cancelable promises were incompatible with ES6 promise semantics, provided one-way cancellation, used unwieldy cancel tokens and have been abandoned.

  • Bluebird stepped aside. Bluebird has bulky stable API and has been largely superseded by ES promises where applicable, particularly due to async..await. Two-way cancellation is disabled by default and incompatible with native promise semantics.

  • No universal third-party options. JavaScript community provides no comprehensive alternatives based on native promises. Renowned p-* package collection only supports one-way cancellation and targets Node.js.

  • Observables aren't a magic bullet. Observables can provide a superset of promise features, as well as cancellation. However, observables don't offer expressive sugar similar to async..await, cancellation may be lost in promise interop. Observables are push-based and cannot displace pull-based async* async iterators. RxJS is commonly used implementation with complex API, no native observable implementation exists yet.

Comparison

canc native + AbortController p-cancelable alkemics/CancelablePromise c-promise2 Bluebird
Native Promise subclass yes n/a no (wraps) no (wraps) no (wraps) no (own implementation)
Deep (chain-wide) cancellation yes manual (thread signal yourself) no (single promise) no (single promise) yes yes
Rejection-based (normal try/catch) yes yes (AbortError) yes no (silent skip) no (never settles) no (never settles by default)
Two-way propagation (bubble up + flow down) yes no no no no no
AbortSignal interop yes (@cancjs/fetch, toolbox helpers) native no no no no
Actively maintained yes n/a (platform) yes no no no (cancellation feature frozen)

canc is the only entry that combines a native Promise subclass with deep, two-way, rejection-based cancellation. AbortController is the maintained platform primitive but only threads a signal, it doesn't propagate cancellation through a chain on its own. The p-*-style packages and alkemics wrap a single promise and skip silently instead of rejecting. c-promise2 has deep cancellation but isn't a Promise subclass and has gone quiet. Bluebird's two-way cancellation predates today's async..await-centric ecosystem, is off by default, and promises never settle on cancel instead of rejecting.

Performance

canc wraps every Promise operation in cancellation bookkeeping, and that costs something. Full numbers (methodology, machine specs, per-suite breakdowns, browser-lane results) live in docs/benchmarks.md; the short version:

In a simulated request waterfall (5 sequential + 3 parallel requests, 30% canceled mid-flight), canc runs well under a microsecond slower per request than a hand-rolled native Promise + AbortController baseline, a relative tax that has ranged from roughly 20% to 80% across repeated runs depending on machine load, typically landing in the 25-50% band, that is dwarfed by any real network or timer latency. For I/O-bound flows (the common case), this overhead is negligible. A fraction of a microsecond of bookkeeping disappears next to a request that takes milliseconds. The cost shows up more in tight, cancellation-heavy loops (a mount/unmount-cancel component lifecycle simulation runs several times slower than native) and in raw construct/chain throughput under isolated microbenchmarks, where canc lands roughly one order of magnitude behind native Promise and is mixed against Bluebird depending on the shape of the chain. Combinator internals (all/race/any/allSettled) were reworked to skip an extra per-item allocation; the measured effect ranges from a modest win to roughly noise-level depending on the case and run (see docs/benchmarks.md), and combinators remain well short of native and mixed against Bluebird.

Memory follows the same pattern. A single canc promise costs a few hundred bytes more than a native one. For high-concurrency workloads (thousands of promises in flight at once, long-lived subscriptions, or streaming/pagination patterns that keep many chains alive), budget roughly an extra 2 MB of retained heap per 1,000 in-flight promises versus native. If a workload creates and discards promises faster than it can await them, this is the number to watch.

None of this changes the tradeoff to make in a given app: canc gives you real, rejection-based, two-way cancellation without hand-wiring AbortController through every call site. Where that's worth a few hundred bytes and a low single-digit-microsecond tax per operation, it's worth using; where a hot loop constructs and cancels promises far faster than any I/O it wraps, measure against your own budget first.

Compatibility

Packages rely on following ECMAScript 2015+ features: Symbol (ES2018 for async iterators), Reflect, Promise (ES2018 for finally, ES2020 for allSettled), Object.assign, Object.setPrototypeOf.

TypeScript

TypeScript floor is 4.2. Each package ships two .d.ts variants and resolves the right one automatically, no consumer configuration needed. TS >= 4.7 reads exports["."].types and gets dist/types/*.d.ts. Older TS falls back to typesVersions (pre-4.7 resolvers don't read exports.types) and gets dist/types-ts4.2/*.d.ts.

The -ts4.2 variant is produced from the standard output via downlevel-dts, plus a follow-up patch for Awaited<T> which predates downlevel-dts's own transform coverage. Verified against a pinned matrix (TS 4.2 / 4.7 / 5.0 / 5.4 / latest) by compiling fixture projects against the built tarballs.

Build targets

All four build outputs (CJS, ESM, UMD, minified UMD) compile from the same ES5-targeted TypeScript source, only the module wrapper differs. CJS is dist/index.cjs (main field), ESM is dist/index.mjs (module field), UMD is dist/index.umd.js and dist/index.umd.min.js (<script>/AMD/CommonJS fallback, unpkg/jsdelivr fields). downlevelIteration is on; no class, #private, bigint, or WeakRef in core source.

Engines

node >= 18 per package engines field. That's the tested and supported Node.js baseline, not a hard floor imposed by the ES5 output itself, the compiled code runs on much older engines too.

Alternative engines (QuickJS, XS/Moddable, Hermes)

Not part of the tested CI matrix, but source targets ES5 with guarded ES2018+ feature use (AggregateError etc), so it should work in principle.

  • QuickJS - spec-compliant ES2020 engine; expected to work with native Promise/Reflect, no known incompatibilities.
  • XS (Moddable) - targets ES2023 with some omissions on constrained builds; verify Reflect and Promise.allSettled availability for your build profile before relying on bubbling behavior.
  • Hermes (React Native) - ships its own Promise polyfill (bytecode-compiled, not a native spec-engine Promise). Species (Symbol.species) and microtask-ordering quirks are the usual hazard surface on polyfilled Promise, so treat those code paths as the risk area if you rely on Hermes.

None of the above are covered by the TS version matrix or CI; treat as best-effort.

Native Support

Tested and supported: Node.js 18+ (CI matrix runs 18.x and 20.x on Linux and Windows), and current evergreen browsers with native Symbol, Reflect, Promise (including finally/allSettled), Object.assign, Object.setPrototypeOf.

The compiled output targets ES5 (see Build targets), so it runs on older engines too, but only the Node 18+ / evergreen-browser baseline above is covered by CI.

Legacy / Polyfilled Support

Older browser and Node.js targets (IE11, Node 6, polyfilled ES5 via core-js/polyfill.io, and similar) are not part of the current test matrix or package set. That support is planned for future -legacy package entries rather than claimed here; see the package list above.

Examples

Runnable examples live in examples, a separate npm install root with its own README.

Contributing

You are welcome to participate through issues and pull requests!

License

MIT