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()23controller.signal4controller.abort()
Once you create a controller instance, you get two things:
- The
signal
property, which is an instance ofAbortSignal
. 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 afetch()
request will abort the request; - The
.abort()
method that, when called, triggers the abort event on thesignal
. 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()23window.addEventListener('resize', listener, { signal: controller.signal })45controller.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)45const 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()34 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 })1314 return () => {15 // Calling `.abort()` removes ALL event listeners16 // 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()34 // Provide the abort signal to this fetch request5 // 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 })1112 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 thesignal
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 takes3 // 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()34channel.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})89const 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'23function 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 })1112 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)23const controller = new AbortController()4const transaction = makeCancelableTransaction(db)56await transaction(7 async (tx) => {8 await tx9 .update(accounts)10 .set({ balance: sql`${accounts.balance} - 100.00` })11 .where(eq(users.name, 'Dan'))12 await tx13 .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()23controller.signal.addEventListener('abort', () => {4 console.log(controller.signal.reason) // "user cancellation"5})67// 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!