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.

States in an async operation

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: AbortControllerabortController = 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: AbortControllerabortController.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: AbortSignalabortSignal = const abortController: AbortControllerabortController.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: booleanisCancelled = const abortSignal: AbortSignalabortSignal.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: AbortSignalabortSignal.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: anyreason = const abortSignal: AbortSignalabortSignal.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.
@sincev0.1.31
createReadStream
} from 'node:fs';
const const abortController: AbortControllerabortController = 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: ReadStreamreadStream = 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.
@sincev0.1.31
createReadStream
('file.txt', {
StreamOptions.signal?: AbortSignal | null | undefinedsignal: const abortController: AbortControllerabortController.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: AbortControllerabortController.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: stringtext = await function readClipboardText(): Promise<string>readClipboardText(); await function displayText(text: string): Promise<void>displayText(const text: stringtext); }

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

  1. 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.

Thanks for reading!

Love what you're reading? Sign up for my newsletter and stay up-to-date with my latest contents and projects.

    I won't send you spam or use it for other purposes.

    Unsubscribe at any time.