Cancellation of asynchronous operations: patterns and techniques

Anton Ioffe - September 29th 2023 - 13 minutes read

In the ever-evolving landscape of web development, mastering the handling of asynchronous operations is a paramount skill for today's developers. An essential part of this is understanding not only how to initiate and manage these operations but also how to effectively and efficiently cancel them when necessary. Welcome to our deep-dive into the fascinating world of cancellation of asynchronous functions in JavaScript.

Navigation through asynchronous logic in JavaScript can be a complex task, often involving the precise management of Promises, callbacks, async/await, and myriad other constructs. Within this article, we unfold the nuanced details of these constructs, along with exploring practical patterns, techniques, and best practices for cancelling ongoing operations. We dissect existing methodologies, critically examining their pros and cons from multiple perspectives including performance, memory usage and code readability.

Spanning from fundamental concepts to advanced topics, our article blends thoughtful explanations with practical, real-world examples and common mistakes to watch out for. We make a deep dive into advanced aspects such as timeout setting, Node.js process cancellation, and the application of AbortController. Brace yourselves for an enlightening journey as you traverse realms from the everyday coding scenarios to the complex corners of JavaScript's asynchronous operations and their cancellation.

Understanding Asynchronous Operations and their Cancellation in JavaScript

Asynchronous programming is a very central part of JavaScript, allowing developers to execute multiple tasks concurrently without blocking the main thread. However, having the ability to cancel ongoing operations can be equally important. In this section, we will delve into the foundations of asynchronous operations and explore how we can cancel these operations when necessary.

The Basics of Asynchronous Operations in JavaScript

Understanding asynchronous operations begins with understanding what they are. Simply put, an asynchronous operation in JavaScript is one that allows the main executing thread to proceed to another task without waiting for the operation to finish. This is particularly crucial in web development where a constant stream of user interaction events necessitates fast processing without any delays.

JavaScript, powered by its single-threaded non-blocking execution model, offers several constructs for handling asynchronous computations and data. These include callbacks, promises, and the async/await syntax.

Callbacks

Callbacks are the most basic and earliest method used to handle asynchronous code in JavaScript. A callback is a function passed to another function as an argument and is executed after a certain operation is completed.

Consider this simple callback function for example:

function download(callback) { 
    // emulate asynchronous download operation
    setTimeout(() => {
        let data = 'some data'; // downloaded data
        callback(data);  
    }, 2000);
}
download(function(data) {
    console.log('Download finished:', data);
});

In this example, the download function initiates an asynchronous operation. Once completed, it executes the callback which logs the downloaded data.

Promises

Promises in Javascript signify the eventual completion or failure of an asynchronous operation along with its resulting value. They present a more structured and readable alternative to callbacks.

Here's how you can initiate a simple Promise:

let promise = new Promise(function(resolve, reject) {
    // Emulate async operation
    setTimeout(() => resolve('operation completed'), 2000);
});
promise.then(result => console.log(result));

A Promise can be in one of these states:

  • pending: initial state, neither fulfilled nor rejected.
  • fulfilled: the operation completed successfully.
  • rejected: the operation failed.

async/await Syntax

The async and await keywords, introduced in ECMAScript 2017, are essentially syntactic sugar over promises. They simplify writing asynchronous code, making it look and behave more like synchronous code.

An async function is one that implicitly returns a promise, whereas the await keyword is used inside an async function to pause and wait for a Promise's resolution or rejection.

Here is an example of how async/await is used:

async function fetchData() {
    let response = await fetch('https://api.example.com/data');
    let data = await response.json();
    console.log(data);
}
fetchData();

Cancelling Asynchronous Operations

At times, one may need to potentially cancel asynchronous operations. Network requests or user interaction-linked operations can occasionally take longer than expected to complete. In such cases, cancelling these ongoing operations improves user experience by not leaving the user waiting. This, while simultaneously reducing the load on your servers.

However, it's important to note that JavaScript and its standard library have no built-in, universal way of canceling operations. It's more about designing and architecting your code such that it supports cancellations.

One simple way to cancel a promise-based asynchronous operation is to create a race condition between your operation and a cancellation signal. This can be done using the Promise.race() method:

let cancelToken = false;

let operation = new Promise((resolve, reject) => {
    setTimeout(() => resolve('operation complete'), 5000);
});

let cancel = new Promise((resolve, reject) => {
    if (cancelToken) {
        reject('operation cancelled');
    } 
});

Promise.race([operation, cancel])
.then(data => console.log(data))
.catch(error => console.error(error));

// toggle cancelToken to cancel operation
cancelToken = true;

In the above example, if cancelToken is set to true before the operation promise resolves, the rejection of the cancel promise would win the race and the catch block will be executed. Conversely, if the operation completes before the cancellation, the operation data is being printed.

Be mindful that this approach has critical limitations. Primarily, it only provides a passive cancellation approach, i.e., it doesn't actively halt an operation but merely dismisses its outcome. As a result, the operation will continue to consume resources until it naturally comes to completion.

In summary, JavaScript provides strong constructs to work with asynchronous code, but lacks a universal method to cancel operations. Nevertheless, understanding the core principles of asynchronous operations and devising ways to handle cancellations can greatly refine your code's efficiency and enhance user experience.

Patterns and Approaches for Cancelling Asynchronous Operations

In modern JavaScript programming, asynchronous tasks are often unavoidable. These tasks run in the background while the main program continues to execute, freeing up processing resources. However, there may be times when we want to cancel these asynchronous operations based on specific conditions, leading us to two common techniques: Promise and async/await cancellation.

1. Promise Cancellation

Promises are one of the foundational elements in handling asynchronous operations in JavaScript. However, they lack a built-in cancellation mechanism. Nevertheless, we can design our own promise cancellation technique.

1.1 Using a Cancelable Promise

A Cancelable Promise pattern involves creating a Promise with an additional cancel method. This pattern modifies the promise object itself, adding a new cancel function.

Here's a simple implementatial example:

function makeCancelable(promise){
    let isCanceled = false;
    
    const cancelablePromise = new Promise((resolve, reject) => {
        promise.then(val => {
            isCanceled ? reject({isCanceled, val}) : resolve(val);
        });
        promise.catch(error => {
            isCanceled ? reject({isCanceled, error}) : reject(error);
        });
    });
    
    return {
        promise: cancelablePromise,
        cancel() {
            isCanceled = true;
        },
    };
}

Pros:

  • Easy to understand and implement
  • Does not require any special libraries or built-in functions

Cons:

  • This pattern doesn't truly cancel the operation, it merely ignores the result
  • If the promise is already settled, the cancellation method has no effect

2. async/await Cancellation

The async/await syntax makes asynchronous code look more like traditional synchronous code, which can be quite appealing. However, cancellation mechanism is not straight forward in async/await.

2.1 Using a Cancellation Token with async/await

Cancellation Tokens are commonly used in many languages to cancel ongoing tasks. It's an object that represents a cancellation request to an async function. You pass this object to the async function which periodically checks the object to see if a cancellation was requested.

Here's a simple implementation:

function cancellableFetch(url, cancellationToken) {
    return new Promise((resolve, reject) => {
        fetch(url).then(response => {
            cancellationToken.isCancellationRequested ? 
                reject({isCanceled: true}): resolve(response);
        });
    });
};
var cancellationToken = { isCancellationRequested: false };
// usage
cancellableFetch('https://api.github.com/', cancellationToken)
    .catch(error => {
        if (error.isCanceled) {
            // Handle cancellation
        } else {
            // Handle error
        }
    });

// to cancel
cancellationToken.isCancellationRequested = true;

Pros:

  • It gives you a sense of control over the async function
  • You can request cancellation at any point

Cons:

  • It is not truly cancellable since it still run till completion
  • You have to manually check for cancellation request, adding code complexity

3. Event Emitter based Cancellation

Event emitters can be quite useful for creating an abortable asynchronous function, thanks to their innately callback-based nature.

Take a look at this simple example:

const EventEmitter = require('events');
class CancelToken extends EventEmitter {}

function asyncTask(cancelToken) {
    return new Promise((resolve, reject) => {
        // Simulate async task 
        setTimeout(() => {
            resolve('Task completed!');
        }, 2000);

        // Listen for cancellation event
        cancelToken.on('cancel', () => {
            reject(new Error('Task was cancelled!'));
        });
    });
}

const cancelToken = new CancelToken();
const task = asyncTask(cancelToken);

// Cancel the task
setTimeout(() => {
    cancelToken.emit('cancel');
}, 1000);

Pros:

  • It provides a more object-based approach to cancellation
  • It allows multiple async functions to listen for cancellation events from the same cancel token

Cons:

  • Listening for an event in an async function can increase code complexity
  • Events can cause memory leaks if not handled properly

4. Generator based Cancellation

While traditionally used in scenarios involving iterable objects, generators can be leveraged to abort an asynchronous task with the help of wrapped promises.

Consider this simple example:

function* generatorFn() {
    // simulate long-running task
    for (let i = 0; i < 1e6; i++) {
        yield i;
    }
} 

const generator = generatorFn();

// initiate long-running task
let nextIteration;
do {
    nextIteration = generator.next();
    // simulate work
    console.log(nextIteration.value);
} while (!nextIteration.done);

// at some point - we want to stop
generator.return();

Pros:

  • Provides the ability to pause and resume an asynchronous task, with the additional benefit of cancellation
  • Built-in to JavaScript and doesn't require extra libraries

Cons:

  • Can be difficult to understand and correctly implement if not familiar with generators
  • Requires an intermediary layer to wrap promises to work properly

Handling the cancellation of asynchronous operations in JavaScript involves understanding and choosing the right approach that fits your particular use-case. Each approach has its own pros and cons to keep in mind, but with a thoughtful design, cancellation in JavaScript doesn't have to lead to frustration.

Real-World Use Cases, Practical Examples, and Common Mistakes

Real-World Use Cases, Practical Examples, and Common Mistakes

One of the most common scenarios where cancelation of asynchronous operations becomes vital is in scenarios where you are making API requests. Highly responsive and interactive user interfaces frequently require the ability to cancel requests in order to reflect the most current data, especially in situations where network latency is a factor. Below represents a typical scenario in JavaScript applications, together with a common error and the corrected counterpart.

Mistake: Not Canceling Old Requests

A frequently occurring oversight is not canceling API requests that are no longer needed. The code snippet below is an illustration of this:

let isLoading = true;
if(isLoading) {
    fetch('https://example.com/data')
    .then(response => response.json())
    .then(data => console.log(data));
}

// Later, isLoading changes
isLoading = false;

In the above code, although isLoading changes to false, the pending request doesn't get terminated. This could potentially lead to unexpected results, especially when a user triggers another action that generates a new request.

Correct Approach: Canceling Old Requests An accurate way to cancel the ongoing fetch request is by using an AbortController - a built-in class in modern browsers for aborting fetch requests.

const controller = new AbortController();
let isLoading = true;

if(isLoading) {
    fetch('https://example.com/data', {signal: controller.signal})
    .then(response => response.json())
    .then(data => console.log(data));
     
    // Later, isLoading changes
    isLoading = false;
    controller.abort();
}

Here we are passing an AbortSignal as an option to the fetch request. Whenever controller.abort() is called, any fetch calls associated with that controller's signal are cancelled.

Overlooking Asynchronously Changing System State

Often developers overlook the impact of async operations on global system state. The issues could be side-effects causing race conditions or creating stale states due to delayed execution of previous operations.

Mistake: Not Considering Asynchronous Side-Effects Here's a situation where such oversight becomes a problem.

let systemState = 0;

async function operation() {
    systemState++;
    let response = await fetch('https://example.com/data');
    let data = await response.json();
    systemState--;
    return data;
}

operation();

In this code, developers expect the systemState to increase by 1 during the async operation. If the operation is called multiple times, and they aren't finished in the order they were called, this could lead to an incorrect systemState.

Correct Approach: Managing Asynchronous Side-Effects We can implement the operation cancellation correctly by waiting for each operation to finish before starting another. A queue or Promise chaining could be a suitable solution here.

let systemState = 0;
let lastPromise = Promise.resolve();

async function operation() {
    lastPromise = lastPromise
    .then(async () => {
        systemState++;
        let response = await fetch('https://example.com/data');
        let data = await response.json();
        systemState--;
        return data;
    });
    return lastPromise;
}

operation();

By chaining the promises, we guarantee that async operations finish in the order they were started. This mitigates the asynchronous side-effects, ensuring our systemState holds the expected value. Though this approach can reduce concurrency, it does help ensure consistency and reduce the risk of misoperation. Should you have any thoughts about this trade-off of concurrency versus consistency in JavaScript's async operations?

Advanced Topics: Timeout, Thread Cancellation, and AbortController

Timeout for Async Operations

When you are handling asynchronous operations, establishing a timeout could be the key in averting indefinitely stalled operations that could potentially block the event loop. Conveniently, JavaScript's native setTimeout() function can be adapted to function with async operations by applying Promises.

A common pitfall emerges when developers misuse setTimeout(), often forgetting to withdraw the timer with clearTimeout() once the operation concludes before the timeout ends. This omission can lead to unnecessary performance overhead as the timer continues its countdown even after the operation completion.

The correct approach is:

function asyncOperationWithTimeout() {
    const timer = setTimeout(() => {
        throw new Error('Operation timed out');
    }, 5000);

    asyncOperation().then(result => {
        clearTimeout(timer);
        return result;
    });
}

The above function will throw an exception if asyncOperation() does not finish within 5 seconds. However, if it does, clearTimeout() nullifies the timeout which in turn, prevents the error from being thrown.

Besides being mindful of correctly using clearTimeout(), developers should also take into consideration the extra memory and CPU cycles consumed by these timers, especially for high frequency or long duration async operations.

Thread Cancellation in Node.js

At times, your application may require a cancellation of a Node.js process. This could either be an asynchronous operation executing in the Node.js thread pool or a dedicated Node.js worker thread. In such cases, invoking a premature terminate() on a thread that doesn't exist or has already finished its task can lead to an avoidable error.

Here's a typical incorrect use:

worker.terminate();

The appropriate technique leads us to verify the worker's existence prior to termination.

Correct approach:

if(worker) {
    worker.terminate();
}

This approach saves us from a possibly non-existent worker or one that already accomplished its task, where an outright terminate() invocation would trigger an error.

At this juncture, it's worth noting that terminating a thread results in an abrupt stop of ongoing operations which can potentially free resources or avoid further CPU time wastage. However, care should be taken for proper clean-up as abrupt stops can also lead to memory leaks if not handled properly.

Using AbortController with Async/Await

AbortController's built-in abort mechanism offers you an avenue to withdraw ongoing async operations in combination with the Fetch API. Instances of AbortController come armed with their own abort() method, which on calling, sends a signal for any ongoing operations to be terminated.

A common mistake copied frequently from various code snippets on the internet showcases a fetch call wrapped in a try-catch block that fails to segregate the abort exception. Thus, the catch block inadvertently collects the abort exception, even when such a scenario might not be the intended outcome.

Incorrect approach:

try {
    const resp = await fetch(url, { signal: abortController.signal });
    // Proceed with response processing
}
catch (err) {
    // Also catches abort exception
}

The optimal strategy?

try {
    const resp = await fetch(url, { signal: abortController.signal });
    // Proceed with response processing
} 
catch (err) {
  if (err.name === 'AbortError') {
     // Treat abort with special handling
  } else {
     // Deal with generic errors
  }
}

This properly structured try-catch block ensures that abort exceptions are caught separately, affording you more granular control over different error situations. The use of AbortController shines particularly in real-world scenarios like buffering multiple fetch requests, where you can abort all requests with a single signal when they are no longer necessary. Not only does it contribute to a better user experience by reducing unnecessary network traffic, but that also affects the overall performance of your web app.

Remember, mastery in these advanced topics relies heavily on a meticulous implementation and error handling. As developers, such advanced topics require patience and practice to perfect. Consider these common mistakes and adhere to the best practices to craft high-quality, efficient, and reliable JavaScript code.

Summary

The article "Cancellation of Asynchronous Operations: Patterns and Techniques" explores the importance of canceling ongoing asynchronous operations in JavaScript. The article covers the basics of asynchronous operations, including callbacks, promises, and the async/await syntax. It also dives into various cancellation techniques such as creating cancelable promises, using cancellation tokens with async/await, event emitter-based cancellation, and generator-based cancellation.

The key takeaway from the article is that while JavaScript offers strong constructs for working with asynchronous code, there is no universal method for canceling operations. Instead, developers need to design their code in a way that supports cancellation. The article provides practical examples, common mistakes, and best practices for handling cancellation of asynchronous operations.

To challenge the reader, a task related to the topic could be to implement a timeout for an asynchronous operation using the setTimeout() function and promises. The reader would need to ensure that the timeout is properly cleared when the operation completes to avoid unnecessary performance overhead. This task would require the reader to apply their understanding of asynchronous operations and handling timeouts in JavaScript.

Don't Get Left Behind:
The Top 5 Career-Ending Mistakes Software Developers Make
FREE Cheat Sheet for Software Developers