Skip to Content
Course content

196: Cancellable Async Workflows

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've seen this bug more times than I can count in production code: a user types quickly into a search box, the UI flickers, and suddenly the results on the screen don't match what's in the input field. It's a classic race condition. You fire off an async request for "a", then "ap", then "app", and because the network is unpredictable, the request for "a" happens to finish last. It overwrites the more specific results, and your user is left wondering why your app is broken.

The Race Condition Trap

The naive way to handle this is to just let the promises resolve and hope for the best, or perhaps try to keep track of a "request ID" manually. You might write something like this:

async function handleSearch(query) {
  const results = await fetch(`/api/search?q=${query}`).then(res => res.json());
  updateUI(results);
}

The problem here is that handleSearch has no way to tell a previous execution, "Hey, I'm the new priority, you can stop now." Once that fetch is in flight, it's a runaway train. Even if you try to use a global variable to track the "latest" request ID, the browser is still downloading the data and the JavaScript engine is still processing the promise resolution. You're wasting bandwidth and CPU cycles on data the user no longer cares about.

The Signal to Stop

To fix this, we need a way to communicate a cancellation signal from the outside into the async operation. In modern JavaScript, that's exactly what the AbortController is for. I like to think of it as a remote kill-switch for any web request.

Instead of just calling fetch, you create a controller and pass its signal to the request. If you call abort() on that controller, the browser immediately cancels the network request. Here is how I'd restructure that search logic:

let searchController = null;

async function handleSearch(query) {
  // If there's a pending request, kill it before starting a new one
  if (searchController) {
    searchController.abort();
  }

  searchController = new AbortController();
  const { signal } = searchController;

  try {
    const response = await fetch(`/api/search?q=${query}`, { signal });
    const results = await response.json();
    updateUI(results);
  } catch (err) {
    if (err.name === 'AbortError') {
      // We intentionally cancelled this, so we can just ignore it.
      return;
    }
    // Handle actual network errors here
    console.error('Search failed:', err);
  }
}

Handling the Aftermath

There is one important detail here that often trips people up: when you call abort(), the promise doesn't just vanish into thin air. It rejects with a specific error called an AbortError. If you don't wrap your await in a try/catch block, you'll end up with an "Uncaught (in promise)" error in your console every single time a user types a character.

You have to decide if a cancellation is a "failure" or a "normal event." In the case of a search-as-you-type feature, it's a normal event. I always filter for err.name === 'AbortError' and return early. This keeps the console clean and ensures that your actual error handling logic (like showing a "Network Error" toast) only triggers when something actually went wrong.

This pattern isn't just for fetch, either. You can pass that same signal into other custom async functions you write. If you have a heavy loop processing data, you can periodically check if (signal.aborted) return; to stop the work mid-stream. It's a powerful way to keep your application responsive and your network tab from looking like a disaster zone.




📋 Practical Task

Implement a Cancellable Typeahead Search

Create a small search interface consisting of an <input> and a <div id="results">. Implement a function that fetches data from the JSONPlaceholder API (https://jsonplaceholder.typicode.com/posts?q=QUERY) as the user types.

  • Use an AbortController to ensure that if the user types a new character before the previous request completes, the previous request is cancelled.
  • Ensure that AbortError exceptions are caught and ignored so they don't clutter the console.
  • Update the #results div with the titles of the posts returned by the API.
  • Verify in the Network tab of your browser's DevTools that previous requests are marked as "(canceled)" when you type quickly.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.