Naive promise interruption
TL;DR
Here’s a wrapper for making any JavaScript promise interruptible via AbortSignal:
function abortable<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
return new Promise<T>((resolve, reject) => {
signal.throwIfAborted()
const onAbort = () => {
signal.removeEventListener("abort", onAbort)
reject(signal.reason as Error)
}
signal.addEventListener("abort", onAbort)
promise.then(resolve, reject).finally(() => {
signal.removeEventListener("abort", onAbort)
})
})
}
When aborted, this eagerly rejects and ignores the result of the original promise.
const controller = new AbortController()
async function doStuff() {
const a = await abortable(slowThingA(), signal)
console.log("got thing a", a)
const b = await abortable(slowThingB(), signal)
console.log("got thing b", b)
const c = await abortable(slowThingC(), signal)
console.log("got thing c", c)
}
doStuff().catch((e) => {
if (!signal.aborted) throw e
console.log("Aborted!")
})
// ...later, to interrupt the sequence of logs
controller.abort()
Note that this is only suitable for promises without side-effects, as the original promise remains alive in the background. Usually you’ll want to write safer logic to interrupt tasks and clean up their resources.
Motivation
AbortSignal makes async JavaScript code more manageable, as you can describe an async sequence without worrying about cancellation conditions at each step. I find this useful when I need to run async procedures in React’s useEffect, which otherwise involves checking whether the component is mounted after each step.
useEffect(() => {
const controller = new AbortController()
const signal = controller.signal
const asyncFn = async () => {
// 👉 Simple linear logic
const a = await slowThingA(signal)
const b = await slowThingB(a, signal)
const c = await slowThingB(b, signal)
console.log(c)
}
asyncFn().catch((e) => {
if (!signal.aborted) throw e
})
return () => {
// 👉 Simple cleanup
controller.abort()
}
}, [])
Unfortunately, many standard async APIs don’t support interruption. For async functions without side-effects such as SubtleCrypto.digest(), this helper makes it easier to interruption a chain of async steps through a standard AbortSignal interface.
Use case: Waiting for custom elements
When interacting with custom elements from React, you may need to call CustomElementRegistry.whenDefined() to wait for the element to be defined before calling methods on it. To avoid double invocations, we can interrupt the async procedure on effect cleanup. Since waiting for the element to be defined doesn’t have side-effects, we don’t care if this promise settles after our effect cleans up.
useEffect(() => {
const myElement = myElementRef.current
if (!myElement) return
const controller = new AbortController()
const asyncFn = async () => {
await abortable(whenDefined("my-element"), controller.signal)
// 👉 This doesn't run if the effect cleaned up first
myElement.doSomething()
}
asyncFn().catch((e) => {
if (!signal.aborted) throw e
})
return () => {
controller.abort()
}
}, [])
This example is simple enough that an unmounted
boolean flag could suffice,
however the AbortSignal pattern composes nicely when you decide to introduce
other async calls or other APIs with native AbortSignal support.
What about Promise.race?
Initially I wrote a version with Promise.race
inspired from
race-abort:
async function raceAbort<T>(promise: Promise<T>, signal: AbortSignal) {
signal.throwIfAborted()
const listenerController = new AbortController()
const res = await Promise.race([
promise,
new Promise<never>((_, reject) => {
// ⚠️ This holds a reference to `signal` even after `promise` resolves
signal.throwIfAborted()
signal.addEventListener("abort", () => reject(signal.reason), {
signal: listenerController.signal,
})
}),
])
listenerController.abort()
return res
}
After reading
this thread
I got paranoid about memory leaks from the signal
reference potentially
outliving the raced promise. Some suggested using Promise.withResolvers
to
mitigate this, but that’s only recently baseline widely available and I’m
hesitant to rely on it just yet, and even that solution has (to me) confusing
lifecycles. The solution I’ve settled on is based on a version from
this comment.
A better approach
If you’re writing a lot async code that requires careful interruption handling, consider using Effect and sleep peacefully knowing that most of the code you write is safely interruptible by default.