Today, I'd like to talk about one of the standard JavaScript APIs you are likely sleeping on. It's called AbortController.

What is AbortController?

AbortController is a global class in JavaScript that you can use to abort, well, anything!

Here's how you use it:

1const controller = new AbortController()
2
3controller.signal
4controller.abort()

Once you create a controller instance, you get two things:

  • The signal property, which is an instance of AbortSignal. This is a pluggable part you can provide to any API to react to an abort event, and implement it accordingly. For example, providing it to a fetch() request will abort the request;
  • The .abort() method that, when called, triggers the abort event on the signal. It also updates the signal to be marked as aborted.

So far so good. But where is the actual abort logic? That's the beauty—it's defined by the consumer. The abort handling comes down to listening to the abort event and implementing the abort in whichever way is suitable for the logic in question:

1controller.signal.addEventListener('abort', () => {
2 // Implement the abort logic.
3})

Let's explore the standard JavaScript APIs that support AbortSignal out of the box.

Usage

Event listeners

You can provide an abort signal when adding an event listener for it to be automatically removed once the abort happens.

1const controller = new AbortController()
2
3window.addEventListener('resize', listener, { signal: controller.signal })
4
5controller.abort()

Calling controller.abort() removes the resize listener from the window. That is an extremely elegant way of handling event listeners because you no longer need to abstract the listener function just so you can provide it to .removeEventListener().

1// const listener = () => {}
2// window.addEventListener('resize', listener)
3// window.removeEventListener('resize', listener)
4
5const controller = new AbortController()
6window.addEventListener('resize', () => {}, { signal: controller.signal })
7controller.abort()

An AbortController instance is also much nicer to pass around if a different part of your application is responsible for removing the listener.

A great "aha" moment for me was when I realized you can use a single signal to remove multiple event listeners!

1useEffect(() => {
2 const controller = new AbortController()
3
4 window.addEventListener('resize', handleResize, {
5 signal: controller.signal,
6 })
7 window.addEventListener('hashchange', handleHashChange, {
8 signal: controller.signal,
9 })
10 window.addEventListener('storage', handleStorageChange, {
11 signal: controller.signal,
12 })
13
14 return () => {
15 // Calling `.abort()` removes ALL event listeners
16 // associated with `controller.signal`. Gone!
17 controller.abort()
18 }
19}, [])

In the example above, I'm adding a useEffect() hook in React that introduces a bunch of event listeners with different purpose and logic. Notice how in the clean up function I can remove all of the added listeners by calling controller.abort() once. Neat!

Fetch requests

The fetch() function supports AbortSignal as well! Once the abort event on the signal is emitted, the request promise returned from the fetch() function will reject, aborting the pending request.

1function uploadFile(file: File) {
2 const controller = new AbortController()
3
4 // Provide the abort signal to this fetch request
5 // so it can be aborted anytime be calling `controller.abort()`.
6 const response = fetch('/upload', {
7 method: 'POST',
8 body: file,
9 signal: controller.signal,
10 })
11
12 return { response, controller }
13}

Here, the uploadFile() function initiates a POST /upload request, returning the associated response promise but also a controller reference to abort that request at any point. This is handy if I need to cancel that pending upload, for example, when the user clicks on a "Cancel" button.

Requests issued by the http module in Node.js also support the signal property!

The AbortSignal class also comes with a few static methods to simplify request handling in JavaScript.

AbortSignal.timeout

You can use the AbortSignal.timeout() static method as a shorthand to create a signal that dispatches the abort event after a certain timeout duration has passed. No need to create an AbortController if all you want is to cancel a request after it exceeds a timeout:

1fetch(url, {
2 // Abort this request automatically if it takes
3 // more than 3000ms to complete.
4 signal: AbortSignal.timeout(3000),
5})

AbortSignal.any

Similar to how you can use Promise.race() to handle multiple promises on a first-come-first-served basis, you can utilize the AbortSignal.any() static method to group multiple abort signals into one.

1const publicController = new AbortController()
2const internalController = new AbortController()
3
4channel.addEventListener('message', handleMessage, {
5 signal: AbortSignal.any([publicController.signal, internalController.signal]),
6})

In the example above, I am introducing two abort controllers. The public one is exposed to the consumer of my code, allowing them to trigger aborts, resulting in the message event listener being removed. The internal one, however, allows me to also remove that listener without interfering with the public abort controller.

If any of the abort signals provided to the AbortSignal.any() dispatch the abort event, that parent signal will also dispatch the abort event. Any other abort events past that point are ignored.

Streams

You can use AbortController and AbortSignal to cancel streams as well.

1const stream = new WritableStream({
2 write(chunk, controller) {
3 controller.signal.addEventListener('abort', () => {
4 // Handle stream abort here.
5 })
6 },
7})
8
9const writer = stream.getWriter()
10await writer.abort()

The WritableStream controller exposes the signal property, which is the same old AbortSignal. That way, I can call writer.abort(), which will bubble up to the abort event on controller.signal in the write() method in the stream.

Making anything abortable

My favorite part about the AbortController API is that it's extremely versatile. So much so, that you can teach your any logic to become abortable!

With such a superpower at your fingertips, not only you can ship better experiences yourself, but also enhance how your are using third-party libraries that don't support aborts/cancellations natively. In fact, let's do just that.

Let's add the AbortController to Drizzle ORM transactions so we are able to cancel multiple transactions at once.

1import { TransactionRollbackError } from 'drizzle-orm'
2
3function makeCancelableTransaction(db) {
4 return (callback, options = {}) => {
5 return db.transaction((tx) => {
6 return new Promise((resolve, reject) => {
7 // Rollback this transaction if the abort event is dispatched.
8 options.signal?.addEventListener('abort', async () => {
9 reject(new TransactionRollbackError())
10 })
11
12 return Promise.resolve(callback.call(this, tx)).then(resolve, reject)
13 })
14 })
15 }
16}

The makeCancelableTransaction() function accepts a database instance and returns a higher-order transaction function that now accepts an abort signal as an argument.

In order to know when the abort happened, I am adding the event listener for the "abort" event on the signal instance. That event listener will be called whenever the abort event is emitted, i.e. when controller.abort() is called. So when that happens, I can reject the transaction promise with a TransactionRollbackError error to rollback that entire transaction (this is synonymous to calling tx.rollback() that throws the same error).

Now, let's use it with Drizzle.

1const db = drizzle(options)
2
3const controller = new AbortController()
4const transaction = makeCancelableTransaction(db)
5
6await transaction(
7 async (tx) => {
8 await tx
9 .update(accounts)
10 .set({ balance: sql`${accounts.balance} - 100.00` })
11 .where(eq(users.name, 'Dan'))
12 await tx
13 .update(accounts)
14 .set({ balance: sql`${accounts.balance} + 100.00` })
15 .where(eq(users.name, 'Andrew'))
16 },
17 { signal: controller.signal }
18)

I am calling the makeCancelableTransaction() utility function with the db instance to create a custom abortable transaction. From this point on, I can use my custom transaction as I normally would in Drizzle, performing multiple database operations, but I can also provide it with an abort signal to cancel all of them at once.

Abort error handling

Every abort event is accompanied with the reason for that abort. That yields even more customizability as you can react to different abort reasons differently.

The abort reason is an optional argument to the controller.abort() method. You can access the abort reason in the reason property of any AbortSignal instance.

1const controller = new AbortController()
2
3controller.signal.addEventListener('abort', () => {
4 console.log(controller.signal.reason) // "user cancellation"
5})
6
7// Provide a custom reason to this abort.
8controller.abort('user cancellation')

The reason argument can be any JavaScript value so you can pass strings, errors, or even objects.

Conclusion

If you are creating libraries in JavaScript where aborting or cancelling operations makes sense, I highly encourage you to look no further than the AbortController API. It's incredible! And if you are building applications, you can utilize the abort controller to a great effect when you need to cancel requests, remove event listeners, abort streams, or teach any logic to be abortable.

Afterword

Special thanks to Oleg Isonen for proofreading this piece!