May 13, 2025
Rethinking Cancellation in Frontend Engineering
Extra complexity for minimal gain
Many frontend engineers, myself included, viewed cancellation as an afterthought. “It’s just a network request, what’s the harm of letting it finish?” This perspective, while understandable, glosses over the implications of having a staling async operations running.
Frankly, cancellation seemed like an unnecessary complication. After all, the request usually reaches the server, and cancelling it on the client doesn’t magically halt the server’s processing. Plus, adding cancellation logic meant another branch of code to manage, another potential source of bug. It felt like adding complexity for minimal gain.
Looking deeper
However, my simplistic view started to shift once I look deeper. It became clear that dismissing cancellation as “nice-to-have” was a just an excuse for my ignorance. Our frontend application often does more than fetch data and display it: subsequent processing, data manipulations, or state updates. A stray, uncancelled request becomes a source of unpredictability, leading to race conditions, inconsistent UI states, and overall application instability.
Consider a scenario where a user rapidly switches between tabs or typing search queries. Without proper cancellation, multiple requests might be fired off, and may be completed in any order. Each response then competes to update the UI, potentially leading to flickering, outdated data, and a confusing experience.
Unpacking cancellation
Let’s discuss few natures of cancellation that may not be obvious at first glance.
Cancellation is an act of intent
We aim to stop an operation, but sometimes it’s simply too late. The request might already be beyond the point of return, or the server might be too far along its process. It’s about doing our best to clean up and prevent unnecessary work, not about guaranteed success.
If we look at the typical states of an async operation above, we can see that cancellation is only possible in the “Pending” state. Once the operation is in “Success” or “Error” state, it’s too late.
Cancellation in JavaScript is usually linked to asynchronous operations
Being a single threaded environment, JavaScript runtime execute synchronous operations in a blocking manner. This means that once a synchronous function or block of code begins, it runs to completion without yielding control back to the caller or any other process until it has finished its work. There is no “gap” for the process to check for a cancellation signal and be interrupted.1
It’s in the gaps between asynchronous operations, those moments where the code wait for a response, that we have the opportunity to check if the caller has the intention to cancel the operation.
Cancellation is more than just that async operation
It’s about cancelling the chain of events that follow. If a network request triggers subsequent processing or updates, cancelling the request also stop those subsequent operations.
Say, the following code:
async function showDashboard() {
const data = await fetchDashboardData();
const widgets = await fetchWidgets();
await displayDashboard(data, widgets);
pollForUpdates();
}
If the fetchDashboardData
or fetchWidgets
operation is cancelled, even if the network requests are already received by the server, we can still skip the displayDashboard
and pollForUpdates
operations.
This broader perspective turns cancellation from a minor async optimization into a key component of ensuring a consistent and reliable user experience.
Incorporating cancellation into your code
The beauty of incorporating cancellation is that it doesn’t necessarily require a complete overhaul of existing code. Most of us already use try ... catch
blocks to handle errors of async operations. The key is to broaden our definition of what is being caught in the catch
phrase. Instead of thinking it’s catching error, think of it as catching exception. Cancellation is just another exception to be handled.
With this view, cancellation is less of an alien concept and more of an extension of our existing error handling.
- You can handle the cancellation in the
catch
block. - If you do not want to handle the cancellation, you can let it propagate, just like normal error handling.
Let’s dive into the details of how to implement cancellation in your code.
Standardized cancellation
JavaScript has two built-in objects to standardize cancellation mechanism: AbortController
and AbortSignal
:
const const abortController: AbortController
abortController = new var AbortController: new () => AbortController
A controller object that allows you to abort one or more DOM requests as and when desired.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortController)
The **`AbortController`** interface represents a controller object that allows you to abort one or more Web requests as and when desired.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortController)AbortController();
// How to abort the operation
// You can pass the cancellation reason, which is defaulted to `AbortError`.
const abortController: AbortController
abortController.AbortController.abort(reason?: any): void (+2 overloads)
Invoking this method will set this object's AbortSignal's aborted flag and signal to any observers that the associated activity is to be aborted.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortController/abort)abort();
//=====
//<- AbortSignal to read/listen for cancellation
const const abortSignal: AbortSignal
abortSignal = const abortController: AbortController
abortController.AbortController.signal: AbortSignal
Returns the AbortSignal object associated with this object.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortController/signal)signal;
// How to check if the operation is cancelled
const const isCancelled: boolean
isCancelled = const abortSignal: AbortSignal
abortSignal.AbortSignal.aborted: boolean
Returns true if this AbortSignal's AbortController has signaled to abort, and false otherwise.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/aborted)aborted;
// How to listen for cancellation, if it's not cancelled yet
const abortSignal: AbortSignal
abortSignal.AbortSignal.addEventListener<"abort">(type: "abort", listener: (this: AbortSignal, ev: Event) => any, options?: boolean | AddEventListenerOptions): void (+3 overloads)
Appends an event listener for events whose type attribute value is type. The callback argument sets the callback that will be invoked when the event is dispatched.
The options argument sets listener-specific options. For compatibility this can be a boolean, in which case the method behaves exactly as if the value was specified as options's capture.
When set to true, options's capture prevents callback from being invoked when the event's eventPhase attribute value is BUBBLING_PHASE. When false (or not present), callback will not be invoked when event's eventPhase attribute value is CAPTURING_PHASE. Either way, callback will be invoked if event's eventPhase attribute value is AT_TARGET.
When set to true, options's passive indicates that the callback will not cancel the event by invoking preventDefault(). This is used to enable performance optimizations described in § 2.8 Observing event listeners.
When set to true, options's once indicates that the callback will only be invoked once after which the event listener will be removed.
If an AbortSignal is passed for options's signal, then the event listener will be removed when signal is aborted.
The event listener is appended to target's event listener list and is not appended if it has the same type, callback, and capture.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget/addEventListener)addEventListener('abort', () => {});
// How to check the reason of cancellation
const const reason: any
reason = const abortSignal: AbortSignal
abortSignal.AbortSignal.reason: any
[MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/reason)reason;
JS API that supports cancellation
You can pass the AbortSignal
to fetch
and addEventListener
to make them cancellable:
async function fetchDashboardData({ abortSignal }: { abortSignal?: AbortSignal }) {
const response = await fetch('/dashboard-data', {
signal: abortSignal,
});
if (!response.ok) {
throw new Error('Failed to fetch dashboard data');
}
return response.json();
}
async function listenForVisibilityChange({ abortSignal }: { abortSignal?: AbortSignal }) {
document.addEventListener(
'visibilitychange',
(ev) => {
fetchDashboardData({});
},
{
signal: abortSignal,
}
);
}
Many NodeJS API also start supporting AbortSignal
as an optional parameter, e.g.
import { function createReadStream(path: PathLike, options?: BufferEncoding | ReadStreamOptions): ReadStream
Unlike the 16 KiB default `highWaterMark` for a `stream.Readable`, the stream
returned by this method has a default `highWaterMark` of 64 KiB.
`options` can include `start` and `end` values to read a range of bytes from
the file instead of the entire file. Both `start` and `end` are inclusive and
start counting at 0, allowed values are in the
\[0, [`Number.MAX_SAFE_INTEGER`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/MAX_SAFE_INTEGER)\] range. If `fd` is specified and `start` is
omitted or `undefined`, `fs.createReadStream()` reads sequentially from the
current file position. The `encoding` can be any one of those accepted by `Buffer`.
If `fd` is specified, `ReadStream` will ignore the `path` argument and will use
the specified file descriptor. This means that no `'open'` event will be
emitted. `fd` should be blocking; non-blocking `fd`s should be passed to `net.Socket`.
If `fd` points to a character device that only supports blocking reads
(such as keyboard or sound card), read operations do not finish until data is
available. This can prevent the process from exiting and the stream from
closing naturally.
By default, the stream will emit a `'close'` event after it has been
destroyed. Set the `emitClose` option to `false` to change this behavior.
By providing the `fs` option, it is possible to override the corresponding `fs` implementations for `open`, `read`, and `close`. When providing the `fs` option,
an override for `read` is required. If no `fd` is provided, an override for `open` is also required. If `autoClose` is `true`, an override for `close` is
also required.
```js
import { createReadStream } from 'node:fs';
// Create a stream from some character device.
const stream = createReadStream('/dev/input/event0');
setTimeout(() => {
stream.close(); // This may not close the stream.
// Artificially marking end-of-stream, as if the underlying resource had
// indicated end-of-file by itself, allows the stream to close.
// This does not cancel pending read operations, and if there is such an
// operation, the process may still not be able to exit successfully
// until it finishes.
stream.push(null);
stream.read(0);
}, 100);
```
If `autoClose` is false, then the file descriptor won't be closed, even if
there's an error. It is the application's responsibility to close it and make
sure there's no file descriptor leak. If `autoClose` is set to true (default
behavior), on `'error'` or `'end'` the file descriptor will be closed
automatically.
`mode` sets the file mode (permission and sticky bits), but only if the
file was created.
An example to read the last 10 bytes of a file which is 100 bytes long:
```js
import { createReadStream } from 'node:fs';
createReadStream('sample.txt', { start: 90, end: 99 });
```
If `options` is a string, then it specifies the encoding.createReadStream } from 'node:fs';
const const abortController: AbortController
abortController = new var AbortController: new () => AbortController
A controller object that allows you to abort one or more DOM requests as and when desired.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortController)
The **`AbortController`** interface represents a controller object that allows you to abort one or more Web requests as and when desired.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortController)AbortController();
const const readStream: ReadStream
readStream = function createReadStream(path: PathLike, options?: BufferEncoding | ReadStreamOptions): ReadStream
Unlike the 16 KiB default `highWaterMark` for a `stream.Readable`, the stream
returned by this method has a default `highWaterMark` of 64 KiB.
`options` can include `start` and `end` values to read a range of bytes from
the file instead of the entire file. Both `start` and `end` are inclusive and
start counting at 0, allowed values are in the
\[0, [`Number.MAX_SAFE_INTEGER`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/MAX_SAFE_INTEGER)\] range. If `fd` is specified and `start` is
omitted or `undefined`, `fs.createReadStream()` reads sequentially from the
current file position. The `encoding` can be any one of those accepted by `Buffer`.
If `fd` is specified, `ReadStream` will ignore the `path` argument and will use
the specified file descriptor. This means that no `'open'` event will be
emitted. `fd` should be blocking; non-blocking `fd`s should be passed to `net.Socket`.
If `fd` points to a character device that only supports blocking reads
(such as keyboard or sound card), read operations do not finish until data is
available. This can prevent the process from exiting and the stream from
closing naturally.
By default, the stream will emit a `'close'` event after it has been
destroyed. Set the `emitClose` option to `false` to change this behavior.
By providing the `fs` option, it is possible to override the corresponding `fs` implementations for `open`, `read`, and `close`. When providing the `fs` option,
an override for `read` is required. If no `fd` is provided, an override for `open` is also required. If `autoClose` is `true`, an override for `close` is
also required.
```js
import { createReadStream } from 'node:fs';
// Create a stream from some character device.
const stream = createReadStream('/dev/input/event0');
setTimeout(() => {
stream.close(); // This may not close the stream.
// Artificially marking end-of-stream, as if the underlying resource had
// indicated end-of-file by itself, allows the stream to close.
// This does not cancel pending read operations, and if there is such an
// operation, the process may still not be able to exit successfully
// until it finishes.
stream.push(null);
stream.read(0);
}, 100);
```
If `autoClose` is false, then the file descriptor won't be closed, even if
there's an error. It is the application's responsibility to close it and make
sure there's no file descriptor leak. If `autoClose` is set to true (default
behavior), on `'error'` or `'end'` the file descriptor will be closed
automatically.
`mode` sets the file mode (permission and sticky bits), but only if the
file was created.
An example to read the last 10 bytes of a file which is 100 bytes long:
```js
import { createReadStream } from 'node:fs';
createReadStream('sample.txt', { start: 90, end: 99 });
```
If `options` is a string, then it specifies the encoding.createReadStream('file.txt', {
StreamOptions.signal?: AbortSignal | null | undefined
signal: const abortController: AbortController
abortController.AbortController.signal: AbortSignal
Returns the AbortSignal object associated with this object.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortController/signal)signal,
});
const abortController: AbortController
abortController.AbortController.abort(reason?: any): void (+2 overloads)
Invoking this method will set this object's AbortSignal's aborted flag and signal to any observers that the associated activity is to be aborted.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortController/abort)abort();
Supporting cancellation in your code
It’s straightforward to support cancellation in your code if the underlying implementation supports it - you just need to start accepting AbortSignal
as an optional parameter and pass it to the underlying implementation.
However, what if the underlying implementation doesn’t support cancellation?
In that case, you can’t cancel the operation, but you can stop the chain of events that follow.
As an example, consider the following snippet:
async function function readClipboardText(): Promise<string>
readClipboardText() {
return var navigator: Navigator
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/navigator)
Returns workerGlobal's WorkerNavigator object.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/WorkerGlobalScope/navigator)navigator.Navigator.clipboard: Clipboard
Available only in secure contexts.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Navigator/clipboard)clipboard.Clipboard.readText(): Promise<string>
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Clipboard/readText)readText();
}
async function function showClipboardText(): Promise<void>
showClipboardText() {
const const text: string
text = await function readClipboardText(): Promise<string>
readClipboardText();
await function displayText(text: string): Promise<void>
displayText(const text: string
text);
}
We want to support cancellation for both readClipboardText
and showClipboardText
functions.
To do that, we can start by accepting AbortSignal
as an optional parameter in both functions:
async function readClipboardText({ abortSignal }: { abortSignal?: AbortSignal }) {
return navigator.clipboard.readText();
}
async function showClipboardText(options: { abortSignal?: AbortSignal }) {
const text = await readClipboardText(options);
await displayText(text);
}
Then, we can use the AbortSignal
in readClipboardText
:
async function readClipboardText({ abortSignal }: { abortSignal?: AbortSignal }) {
abortSignal?.throwIfAborted();
return new Promise<string>((fulfill, reject) => {
abortSignal?.addEventListener('abort', () => reject(abortSignal.reason));
navigator.clipboard.readText().then(fulfill).catch(reject);
});
}
Note that even though navigator.clipboard.readText
could not be cancelled, we can still cancel the chain of events that follow (execute displayText
) if the AbortSignal
is aborted before readClipboardText
is completed.
Conclusion
Cancellation in frontend engineering is more than just stopping network requests - it’s about maintaining control over your application’s state and user experience. While it may seem like an optional optimization, proper cancellation handling is essential for preventing race conditions, reducing unnecessary processing, and ensuring UI consistency. The good news is that modern JavaScript provides robust tools like AbortController
and AbortSignal
that make implementing cancellation straightforward. By treating cancellation as a first-class concern alongside error handling, you can build more reliable and responsive applications without significantly increasing code complexity.
Footnotes
-
Technically, you can make a synchronous function cancellable by checking for the cancellation signal within the function. However, it is seldom done in practice because checking for cancellation signal introduces additional overhead. ↩