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.