“Abortable Promise” Utility Functions
Written in response to “How to cancel a JavaScript promise that has already started”.
How about coming up with an implementation for an “Abortable Promise” (unless it is settled) where the API layer does abstract away possible traps that one might trip into when following the OP’s example code?
Based on a possible implementation such as e.g. Promise.withAbort, the original article’s latter example for instance could be simplified to something as straightforward as ...
function longRunningTask(resolve, reject, abort, signal) {
console.log('Time-consuming task started...');
setTimeout(resolve, 5_000, 'Success!');
// - `reject` as well, if needed.
// - auto-`abort`, if necessary.
// - utilize `signal` as it seems fit.
}
const { promise, resolve, reject, abort, signal } = Promise.withAbort();
signal.addEventListener('abort', evt =>
// e.g. the one-time cleanup-task.
console.log('Time-consuming task aborted ...', { evt }), {
once: true
}
);
promise
.then(result => console.log('Time-consuming task fulfilled!'))
.catch(reason =>
// covers both `reject` and `abort`.
console.log('Time-consuming task rejected ...', { reason })
);
// cancel/abort the pending long running task after 2 seconds.
setTimeout(abort, 2_000, new DOMException(
'Exceeded pending threshold.', 'AbortError'
));
// trigger/start the long running task.
longRunningTask(resolve, reject, abort, signal);… in comparison to its former version …
function LongRunningTask(signal) {
return new Promise((resolve, reject) => {
// If the signal is aborted before the task starts, reject directly
if (signal.aborted) {
return reject(new DOMException('Task aborted', 'AbortError'));
}
console.log('Time-consuming task started...');
const timeoutId = setTimeout(() => {
console.log('Time-consuming task completed!');
resolve('Success!');
}, 5000);
// Listen for the abort event for cleanup
signal.addEventListener('abort', () => {
clearTimeout(timeoutId); // Clear the timer to actually abort the operation
console.log('Timer cleared, task aborted.');
reject(new DOMException('Task aborted', 'AbortError'));
});
});
}
// --- Usage Example ---
const controller = new AbortController();
LongRunningTask(controller.signal)
.then(console.log)
.catch((err) => {
if (err.name === 'AbortError') {
console.log('AbortError caught, task successfully canceled.');
}
});
// Cancel the task after 2 seconds
setTimeout(() => {
controller.abort();
}, 2000);The Promise.withAbort abstraction liberates one from implementing the bug and race-condition free abort-handling separately and repetitively for every given custom abortable task.
As for the OP’s above example code, I would add the promise rejecting abort listener immediately after the LongRunningTask‘s clause that catches signal.aborted; however an implemented Promise.withAbort API already deals with that through code which sits behind its provided abort resolver.
Other obstacle are the memory-safe, non-leaking handling of abort related timeouts and handlers and the correct abortion of exclusively pending promises. Any promise’s resolver function like resolve, reject and abort has to become a no-op in the very moment this promise settles.
An “Abortable Promise” API has to take care of that as well. And it might not only provide a single additional static utility method like the already introduced Promise.withAbort (similar to Promise.withResolvers), but it might provide a pendant to new Promise(define) too; for instance another static method like Promise.abortable(define).
The next provided code does achieve all of the above mentioned goals …
/**
* This callback is the custom provided executor-function
* which gets all of an abortable promise's specific resolver
* functions and its abort signal passed into.
* This callback defines how the created promise does settle.
*
* @callback abortablePromiseExecutor
* @param {(value?: any) => void} resolve
* Does resolve its promise with a result.
* @param {(reason?: any) => void} reject
* Does reject its promise with a reason.
* @param {(reason?: any) => void} abort
* Does abort its promise with a reason.
* @param {AbortSignal} signal
* Enables listening to its promise's `abort` event.
*/
/**
* An object which features all of an abortable promise's resolver
* functions, its abort signal and the abortable promise itself.
*
* @typedef {Object} AbortablePromiseWithResolversAndSignal
* @property {Promise} promise
* The abortable promise itself.
* @property {(value?: any) => void} resolve
* Does resolve its promise with a result.
* @property {(reason?: any) => void} reject
* Does reject its promise with a reason.
* @property {(reason?: any) => void} abort
* Does abort its promise with a reason.
* @property {AbortSignal} signal
* Enables listening to its promise's `abort` event.
*/
class AsyncAbortableExecuteError extends Error {
constructor(cause) {
super(
'The custom provided callback does throw. Please check the `cause`.',
{ cause }
);
this.name = 'AsyncAbortableExecuteError';
}
}
class AsyncAbortError extends Error {
constructor(message, options) {
message = String(message ?? '').trim() || 'Pending async task aborted.';
options = options ?? {};
super(
message,
(Object.hasOwn(options, 'cause') && { cause: options.cause }) || void 0
);
this.name = 'AsyncAbortError';
}
}
function handleAsyncAbort({ currentTarget: signal }) {
const { reject, state } = this;
// guard.
if (state.pending) {
const { reason } = signal;
const abortError =
(Error.isError(reason) && reason.name === 'AbortError' && reason) ||
new AsyncAbortError(null, { cause: reason });
if (!abortError.message || !String(abortError.message).trim()) {
abortError.message = 'Pending async task aborted.';
}
// - `reject` is going to take care of removing
// the bound version of this handler-function
// from the related internal abort-controller.
reject(abortError);
}
}
/**
* Core functionality which defines and manages state
* and control-flow of an abortable promise, be it a
* promise defined by a custom executor-callback, or
* a promise with resolvers.
*
* @param {abortablePromiseExecutor} [executeCustom]
* A custom provided callback which defines how the
* created promise settles.
* @returns {Promise|AbortablePromiseWithResolversAndSignal}
*/
function defineAsyncAbortable(executeCustom) {
const { promise, resolve, reject } = Promise.withResolvers();
const asyncState = {
fulfilled: false,
rejected: false,
aborted: false,
pending: true,
};
const listenerController = new AbortController();
const abortController = new AbortController();
const abortSignal = abortController.signal;
const resolvePromise = ((proceed, state, controller) =>
// create and return a concise generic `resolve` resolver.
({
resolve(value) {
// guard.
if (state.pending) {
state.pending = false;
state.fulfilled = true;
// listener controller.
controller.abort();
proceed(value);
}
},
}['resolve']))(resolve, asyncState, listenerController);
const rejectPromise = ((proceed, state, controller) =>
// create and return a concise generic `reject` resolver.
({
reject(reason) {
// guard.
if (state.pending) {
state.pending = false;
state.rejected = true;
// listener controller.
controller.abort();
proceed(reason);
}
},
}['reject']))(reject, asyncState, listenerController);
const abortPromise = ((state, controller) =>
// create and return a concise generic `abort` resolver.
({
abort(reason) {
// guard.
if (state.pending && !state.aborted) {
state.aborted = true;
// abort controller.
controller.abort(reason);
}
},
}['abort']))(asyncState, abortController);
const abortHandler = handleAsyncAbort.bind({
reject: rejectPromise,
state: asyncState,
});
abortSignal.addEventListener('abort', abortHandler, {
signal: listenerController.signal,
});
let result;
if (executeCustom) {
try {
executeCustom(resolvePromise, rejectPromise, abortPromise, abortSignal);
} catch (exception) {
rejectPromise(new AsyncAbortableExecuteError(exception));
}
// abortable ... {Promise}
result = promise;
} else {
// with abort ... {AbortablePromiseWithResolversAndSignal}
result = {
promise,
resolve: resolvePromise,
reject: rejectPromise,
abort: abortPromise,
signal: abortSignal,
};
}
return result;
}
/**
* `Promise.abortable(executor)`
*
* Creates an abortable promise; the custom provided executor-callback
* ... `executeCustom(resolve, reject, abort, signal)` ... defines how
* such a promise is going to settle.
*
* @param {abortablePromiseExecutor} executeCustom
* A custom provided callback which defines how the created promise
* settles.
*/
function abortable(executeCustom) {
if (typeof executeCustom !== 'function') {
throw new TypeError(
'The single mandatory argument must be a function type.'
);
}
return defineAsyncAbortable(executeCustom);
}
/**
* `Promise.withAbort()``
*
* Returns `{ promise, resolve, reject, abort, signal }` so one
* can wire an abortable promise without an executor-callback.
*
* @returns {AbortablePromiseWithResolversAndSignal}
*/
function withAbort() {
return defineAsyncAbortable();
}
const descriptorOptions = {
enumerable: false, writable: false, configurable: true,
};
// apply minifier safe function names.
Object.defineProperty(abortable, 'name', {
...descriptorOptions, value: 'abortable',
});
Object.defineProperty(withAbort, 'name', {
...descriptorOptions, value: 'withAbort',
});
// introduce two new static `Promise` methods.
Object.defineProperty(Promise, 'abortable', {
...descriptorOptions, value: abortable,
});
Object.defineProperty(Promise, 'withAbort', {
...descriptorOptions, value: withAbort,
});Usage examples, in addition to the very first provided one, which prove the correct implementation of both Promise.abortable and Promise.withAbort are …
/*
* test case 1 ... `Promise.abortable`
*/
const aborted = Promise.abortable((resolve, reject, abort/*, signal*/) => {
setTimeout(resolve, 300, 'smoothly fulfilled');
setTimeout(reject, 200, 'plainly rejected');
setTimeout(abort, 100, 'too long pending task'); // <==
});
const rejected = Promise.abortable((resolve, reject, abort/*, signal*/) => {
setTimeout(resolve, 200, 'smoothly fulfilled');
setTimeout(reject, 100, 'plainly rejected'); // <==
setTimeout(abort, 300, 'too long pending task');
});
const fulfilled = Promise.abortable((resolve, reject, abort/*, signal*/) => {
setTimeout(resolve, 100, 'smoothly fulfilled'); // <==
setTimeout(reject, 100, 'plainly rejected');
setTimeout(abort, 100, 'too long pending task');
});
setTimeout(() => console.log({ aborted, rejected, fulfilled }), 200);
/*
* test case 2 ... `Promise.withAbort`
*/
async function safeAsyncResult(future, ...args) {
let error = null;
let value;
// roughly implemented in order to mainly cover the test scenario.
const promise = typeof future === 'function' ? future(...args) : future;
try {
value = await promise;
} catch (exception) {
error = exception;
}
// safe result tuple.
return [error, value];
}
function getRandomNonZeroPositiveInteger(maxInt) {
return Math.ceil(Math.random() * Math.abs(parseInt(maxInt, 10)));
}
async function createAbortableRandomlySettlingPromise() {
const { promise, resolve, reject, abort, signal } = Promise.withAbort();
// list of values of either ... 300, 400, 500.
const randomDelayList = [
200 + getRandomNonZeroPositiveInteger(3) * 100,
200 + getRandomNonZeroPositiveInteger(3) * 100,
200 + getRandomNonZeroPositiveInteger(3) * 100,
];
//debugger;
setTimeout(resolve, randomDelayList.at(0), 'resolved with value "foo".');
setTimeout(reject, randomDelayList.at(1), 'rejected with reason "bar".');
setTimeout(abort, randomDelayList.at(2), 'running for too long');
return promise;
}
const resultList = await Promise.allSettled(
Array.from({ length: 9 }, () =>
safeAsyncResult(createAbortableRandomlySettlingPromise)
)
);
const safeResultList = resultList.map(({ value }) => value);
console.log({ safeResultList });Final Reasoning
Helper functions like the shown Promise.abortable and Promise.withAbort are sufficient as tiny abstraction layers. There is no practical gain in implementing a subclassed AbortablePromise that extends JavaScript’s built-in Promise. On the contrary, going down that route opens a new set of questions: should there be a prototypal abort method on instances; how does one shape an unmistakably safe API for only abortable promises (or a mixed world of regular and abortable promises)? Since there is no clean answer that improves on the helper approach, one would eventually end up with an AbortablePromise whose then and catch still return regular promises. Abort handling would still live in a dedicated abort resolver, and 'abort' listening would still rely on each promise’s linked AbortSignal. In other words: stick to regular promises and the utility methods introduced above.
Takeaways
- Helpers beat subclasses. Tiny, explicit helpers (
Promise.abortable,Promise.withAbort) give one predictable cancellation without changing how promises chain or howawaitbehaves. - Abort ⇒ rejection (by design). Aborting maps to a rejection (e.g.,
AsyncAbortError), while an internalabortedflag captures intent for diagnostics. - Clear contract. Every executor gets the same quartet:
(resolve, reject, abort, signal). Cleanup is automatic; listeners are removed on first settlement. - Strict invariants. Exactly-once settlement; abort is idempotent; settled state is immutable;
signal.reasontunnels into the rejection cause. - Races made visible. Equal-delay examples are intentional: they expose real-world race behavior while guards ensure no double-settlement.
- Interop-friendly. Plain promises are returned, so
await,all,race, and ecosystem libraries behave normally. No species tricks, no subclass surprise. - Composable with existing APIs. The
AbortSignalplugs straight into fetch, timers, and any userland task that supports cancellation hooks. - Easy to test. The design invites small assertions (cleanup, idempotency, race ordering), making correctness demonstrable, not assumed.
- Ergonomics first. One keeps the mental model of regular promises and adds just enough surface to make cancellation explicit and safe.
