Aborting side-effects

January 26, 2019

Side-effects are painful and should be avoided, but as pain itself, they are an essential part of life if you are a web application. The most common side-effect by far is an HTTP request. One can think of many reasons why aborting an HTTP request might be a good idea:

  • change of plans -- don't download that big file, don't send that sensitive email
  • vitual scrolling, when in a few milliseconds we could be sending a few requests, it would be great to gain some bandwidth by only ever having a single active request
  • similarly, in an auto-complete component, where each keystroke may be initiating a request, though we only ever care about the latest one

In some of these cases, there is also the possibility of race conditions, where request A completes after request B. If we don't put some guards, this will lead to unexpected states.

Consider also the case where a state object that is reset to its initial state while a side-effect was ongoing: when the side-effect completes, it might have invalid expectations about the current state and fail, or update the state object into an undesired state.

Of course, you can also guard against this with TypeScript, trying to make invalid states impossible, but this is often harder than it seems and doesn't scale because you end up having to create type guard functions to identify the current state type.

You could also be tempted to add something to your state to check if it's active or not. Something like isEnabled. This can work in some cases, but only if you know for sure that your state will always be expected to be mounted, like in a single-store application. But even in that case, I'd argue it's a bad practice that will make different parts of your state more tightly coupled, and your state management dependant on conditions outside of its domain. Short-lived stores are more common than you might think for large applications that don't want to fully migrate to a single framework, or that are transitioning.

In such cases something like isEnabled could prevent you from noticing possible memory leaks. After all, if you are checking isEnabled it's because you know you may be holding reference to this state when it should possibly be unmounted. This is akin to isMounted, a method that was removed from React for similar reasons. Quoting the React team, from 2015:

An optimal solution would be to find places where setState() might be called after a component has unmounted, and fix them. Such situations most commonly occur due to callbacks, when a component is waiting for some data and gets unmounted before the data arrives. Ideally, any callbacks should be canceled in componentWillUnmount, prior to unmounting.

And in reality this is exactly the same problem we are discussing here.

The article goes on to recommend cancelable Promises, which works, except in the most common case, which we agreed is HTTP requests.

Aborting XMLHttpRequest is possible, but the spec is vague in terms of how the network operations are handled or in case of a race condition between aborting and the completion of the request.

For a while it was not possible to abort fetch requests, and it wasn't uncommon to resort to libraries like axios, which got the job done. The situation has improved for modern, up-to-date browsers (except Safari!), with AbortController. Resorting to some polyfills it should be possible to abort fetch requests in most use cases.

We don't know what the future will look like in terms of standards around Promises in general, and the result of past discussions and proposals is not encouraging, but thankfully it's possible to use AbortController to cancel promises just like with fetch requests.

To understand how AbortController works, we should look at the few actors involved in aborting a side-effect:

  • the controller: an instance of AbortController. We pass its signal prop to any side-effect we want aborted when we call abort() on the controller.
  • the signal: an instance of AbortSignal tied to a controller, passed to side-effect functions. It emits abort events when abort() is called on its origin controller.
  • the side-effect: it receives a signal and listens for its abort event to know when to abort its work, after which it throws an abort error.
  • the abort error: a DOMException instance with name AbortError.

The following example shows all actors in action to allow a user to cancel the download of a large video (adapted from a full working example).

js
const controller = new AbortController();
const signal = controller.signal;
const downloadBtn = document.querySelector('.download');
const abortBtn = document.querySelector('.abort');
const reports = document.querySelector('.reports');
downloadBtn.addEventListener('click', fetchVideo);
abortBtn.addEventListener('click', () => {
controller.abort();
console.log('Download aborted');
});
async function fetchVideo() {
try {
const response = await fetch(url, {signal});
// do something with the response.
} catch (e) {
reports.textContent = `Download error: ${e.message}`;
}
}

We don't get to see what fetch is doing internally, but as per the spec:

  1. it checks that the signal has not been aborted yet
  2. it starts listening for abort events on the signal

When it detects that the signal has been aborted, either at the beginning or later through the event listener, it throws an abort error.

Let's see what our own cancelable side-effect function would look like, also based on examples from the spec:

js
function doAmazingness({signal}) {
if (signal.aborted) {
return Promise.reject(new DOMException('Aborted', 'AbortError'));
}
return new Promise((resolve, reject) => {
// Begin doing amazingness, and call resolve(result) when done.
// But also, watch for signals:
signal.addEventListener('abort', () => {
// Stop doing amazingness, and:
reject(new DOMException('Aborted', 'AbortError'));
});
});
}

One advantage of this approach is that we can cancel multiple requests with one abort call. Let's change the fetchVideo function from the earlier example so that it can download and abort two video downloads.

js
async function fetchVideos() {
try {
const video1 = await fetch(url1, {signal});
const video2 = await fetch(url2, {signal});
// do something with the response.
} catch (e) {
reports.textContent = `Download error: ${e.message}`;
}
}

Both requests will be aborted, but only one exception will be thrown.

In my next post I'll explain how to abort side effects this safely inside a React component.


Profile picture

I'm a Software Engineer from Catalonia based in North Carolina. I'm generally busy with work and 3 boys, but I'll try to drop some thoughts here when I can.

You can also follow me on Blueky, Twitter, LinkedIn and GitHub.

© 2024, Kilian Cirera Sant