How to Cancel Async Code in JavaScript

Canceling asynchronous operations in JavaScript has historically been challenging. Developers often faced issues like race conditions, memory leaks, and complex error-handling scenarios without a simple way to interrupt ongoing operations. These challenges could lead to less responsive and harder-to-maintain applications.

That's why I was happy to learn about AbortController API and its companion, AbortSignal. Together, they provide a standardized and simple way to cancel asynchronous operations. Let's understand how they work together and how to use them effectively.

AbortController and AbortSignal

There are two main ways to create abort signals in JavaScript:

  • Using AbortController: Creates a manually controlled abort signal
  • Using AbortSignal Methods: Creates standalone signals with specific behaviors (like timeout)

I'll modify the examples to use setTimeout, making them more interactive and easier to test in the console. I'll also expand the explanations for better clarity.

Manual Control with AbortController

The AbortController lets you manually cancel operations. Here's a practical example using setTimeout:

// Create a controller and get its signal
const controller = new AbortController();
const signal = controller.signal;

// Set up an event listener to know when abortion happens
signal.addEventListener('abort', () => {
    console.log('Operation was cancelled!');
    console.log('Abort reason:', signal.reason);
});

// Simulate a long-running operation with setTimeout
const timeoutId = setTimeout(() => {
    if (!signal.aborted) {
        console.log('Operation completed successfully!');
    }
}, 5000);

// Add abort handling to clean up the timeout
signal.addEventListener('abort', () => {
    clearTimeout(timeoutId);
});

// The signal starts in a non-aborted state
console.log('Initial aborted state:', signal.aborted); // false

// You can run this in your console to cancel the operation before it completes:
// controller.abort('Operation cancelled by user');

Think of this like a TV remote - the controller is in your hands, and you can press the power button (abort) whenever you want to stop the show. Try running this code in your console and then calling controller.abort() before the 5 seconds are up!

Automatic Signals with AbortSignal

AbortSignal provides convenient static methods for automatic cancellation. Here's a practical example:

// Create a signal that will automatically abort after 3 seconds
const signal = AbortSignal.timeout(3000);

// Set up an event listener to know when the timeout occurs
signal.addEventListener('abort', () => {
    console.log('Operation timed out!');
    console.log('Reason:', signal.reason);
});

// Simulate a long-running operation
const timeoutId = setTimeout(() => {
    if (!signal.aborted) {
        console.log('Operation completed successfully!');
    }
}, 5000); // This is longer than our timeout

// Clean up the timeout when aborted
signal.addEventListener('abort', () => {
    clearTimeout(timeoutId);
});

// You can also check the current state
console.log('Initial aborted state:', signal.aborted); // false

Run this code in your console and watch how the operation is automatically canceled after 3 seconds before the 5-second setTimeout can complete.

Combining Multiple Signals

You can also combine signals using AbortSignal.any(). Here's a practical example:

// Create two signals: one manual and one automatic
const controller = new AbortController();
const manualSignal = controller.signal;
const timeoutSignal = AbortSignal.timeout(4000);

// Combine the signals
const combinedSignal = AbortSignal.any([manualSignal, timeoutSignal]);

// Listen for abortion
combinedSignal.addEventListener('abort', () => {
    console.log('Operation cancelled!');
    console.log('Reason:', combinedSignal.reason);
});

// Simulate a long operation
const timeoutId = setTimeout(() => {
    if (!combinedSignal.aborted) {
        console.log('Operation completed successfully!');
    }
}, 5000);

// Clean up on abort
combinedSignal.addEventListener('abort', () => {
    clearTimeout(timeoutId);
});

// Now the operation will be cancelled if either:
// 1. You manually call controller.abort()
// 2. The 4-second timeout is reached

This is like having both a remote control and a sleep timer on your TV—whichever triggers first will turn off the TV. Try running this code and calling controller.abort() or waiting for the timeout to see how it works!

These examples are all runnable in your browser's console and provide immediate feedback, making it easier to understand how AbortController and AbortSignal work in practice.

When to Use Each

Use AbortController When:

  • You need manual control over cancellation
  • User interactions trigger the cancellation
  • You want to cancel multiple operations at once
  • You need to cancel based on complex conditions
const controller = new AbortController();

// User can cancel anytime
cancelButton.addEventListener('click', () => {
    controller.abort();
});

fetch(url, { signal: controller.signal });

Use AbortSignal.timeout() When:

  • You just need a simple timeout
  • You want cleaner code without managing timeouts manually
  • The cancellation condition is time-based
// Clean, simple timeout
fetch(url, { signal: AbortSignal.timeout(5000) });

Example: Downloads with Cancel Button

I'm sure you are getting the picture at this stage, but to go a level deeper, let's do something more practical.

One common use case is allowing users to cancel ongoing operations. Here's how you might implement a cancellable download:

const downloadBtn = document.querySelector('.download');
const cancelBtn = document.querySelector('.cancel');
let controller;

downloadBtn.addEventListener('click', startDownload);
cancelBtn.addEventListener('click', () => {
    if (controller) {
        controller.abort();
        console.log('Download canceled');
    }
});

async function startDownload() {
    controller = new AbortController();
    
    try {
        const response = await fetch('https://example.com/large-file', {
            signal: controller.signal
        });
        console.log('Download complete');
    } catch (error) {
        if (error.name === 'AbortError') {
            console.log('Download was canceled');
        } else {
            console.log('Download failed:', error);
        }
    }
}

This pattern is handy for long-running operations where user cancellation is important for the experience.

Error Handling Considerations

When working with AbortController and AbortSignal, you'll encounter two main types of errors:

  • AbortError: Thrown when an operation is explicitly canceled via controller.abort()
  • TimeoutError: Thrown when using AbortSignal.timeout() and the time limit is reached

Handling these differently from other errors is essential since they represent expected cancellation scenarios rather than actual errors.

Some Tips

When implementing cancellation in your applications:

  • Create Fresh Controllers: Create a new AbortController for each operation
  • Clean Up: Clear any references to the controller once the operation is complete
  • Handle Errors: Always catch and handle AbortError and TimeoutError appropriately
  • User Feedback: Provide clear feedback when operations are canceled

As you implement these patterns in your code, remember that the goal is to create more responsive, user-friendly experiences. Consider where cancellation would benefit your users, and implement it naturally and intuitively.

NodejsJavaScript
Avatar for Niall Maher

Written by Niall Maher

Founder of Codú - The web developer community! I've worked in nearly every corner of technology businesses: Lead Developer, Software Architect, Product Manager, CTO, and now happily a Founder.

Loading

Fetching comments

Hey! 👋

Got something to say?

or to leave a comment.