DEV Community

Cover image for Cancelling asynchronous operations with AbortController
Schalk Neethling
Schalk Neethling

Posted on • Originally published at schalkneethling.com

Cancelling asynchronous operations with AbortController

I have worked on a few projects where client-side fetching of data was required. One of the challenges we ran into several times, is what to do about a request if the user navigates away from the current view. Turns out there is a well-supported Web API to do just that. This API is available in the browser and Node.js. Let’s have a look.

Let’s say you have a select drop-down with a list of cities. When a user selects a city, you make a call to the WeatherDB API to get the current weather for that city. Normally the time between the request and the response will be pretty quick and you will most likely not be concerned about aborting the request. For our discussion here though, let’s create two possible scenarios.

  1. The response from the server hangs for a long time.
  2. The time between the request and the response is long enough, that the user could choose a different city before the response is received.

A long server response time

Looking at the first scenario, we can implement a timeout, abort the request, and provide the user with some feedback.

// The URL below explicitly waits for 3 seconds before sending a response
const url = "https://simple-node-server.vercel.app/slow-response";
const abortController = new AbortController();
const { signal } = abortController;
const response = fetch(url, { signal });

const outputContainer = document.getElementById("output");

const timeout = setTimeout(() => {
  abortController.abort();
  console.warn("request cancelled after 2 seconds");
}, 2000);

response
  .then((response) => {
    if (!response.ok) {
      throw new Error(`HTTP error: ${response.status}`);
    }
    return response.json();
  })
  .then((json) => {
    outputContainer.textContent = JSON.stringify(json);
    clearTimeout(timeout);
  })
  .catch((err) => {
    if (err.name === "AbortError") {
      outputContainer.textContent = `${err.name}: ${err.message}`;
      console.error(`${err.name}: ${err.message}`);
    } else {
      // if it was not an AbortError, throw the error so it propagates
      throw err;
    }
  });
Enter fullscreen mode Exit fullscreen mode

That is a lot of code. Let’s break it down.

For this post, I created a super simple Nodejs server that responds to two routes. The URL we are using here, as the code comment mentions, has an explicit 3-second delay. Next, we create an AbortController which is the API we are going to use to communicate with our asynchronous function a little later. We destructure the signal property from the AbortController and assign it to a variable. Next, we make a call to the fetch function with the URL and the signal as the second argument. Doing this is what will allow us to communicate with the fetch request and abort it.

The next line gets a reference to a DOM element with the id of output. We use this to display the response from the server or a message if we abort the request.

<p id="output"></p>
Enter fullscreen mode Exit fullscreen mode

We can now set up our timeout that will abort the request if it takes longer than 2 seconds to respond.

const timeout = setTimeout(() => {
  abortController.abort();
  console.warn("request cancelled after 2 seconds");
}, 2000);
Enter fullscreen mode Exit fullscreen mode

Nothing new here in terms of the setTimeout function. We store a reference to it in a variable called timeout and schedule the function to be called after 2000 milliseconds(2 seconds) have passed. When the function is called, we call the abort function on the AbortController to abort the fetch request. We here call the function without specifying a reason and so, the default reason of AbortError will be used. You could also do this for example:

abortController.abort("RequestTimeout");
Enter fullscreen mode Exit fullscreen mode

We next start our Promise chain to handle the response that came back from the fetch request.

response.then((response) => {
  if (!response.ok) {
    throw new Error(`HTTP error: ${response.status}`);
  }
  return response.json();
});
Enter fullscreen mode Exit fullscreen mode

We first ensure that our request is successful and throw an Error if it isn’t. We then call the json function on the response to get the response body as a JSON object.

NOTE: I am not going to dig into the details of fetch here so, you can read more about the Response object and its methods on MDN Web Docs.

response
  .then((response) => {
    if (!response.ok) {
      throw new Error(`HTTP error: ${response.status}`);
    }
    return response.json();
  })
  .then((json) => {
    outputContainer.textContent = JSON.stringify(json);
    clearTimeout(timeout);
  });
Enter fullscreen mode Exit fullscreen mode

If all is good, we move to the next step of handling our response. Here we stringify and output the JSON to the output container. On the next line, we call the clearTimeout function and pass in the timeout variable we created earlier. This will then prevent the callback of our setTimeout function from being called as the response was received in a timely manner.

response
  .then((response) => {
    if (!response.ok) {
      throw new Error(`HTTP error: ${response.status}`);
    }
    return response.json();
  })
  .then((json) => {
    outputContainer.textContent = JSON.stringify(json);
    clearTimeout(timeout);
  })
  .catch((err) => {
    if (err.name === "AbortError") {
      outputContainer.textContent = `${err.name}: ${err.message}`;
      console.error(`${err.name}: ${err.message}`);
    } else {
      // if it was not an AbortError, throw the error so it propagates
      throw err;
    }
  });
Enter fullscreen mode Exit fullscreen mode

Things do not always go as planned though so, it is best practice to always end an asynchronous operation with a catch block to handle errors. In the above code we first check if the error was of type AbortError. If it was, we output the error message to the output container and log it to the console. If it wasn’t, we throw the error so it propagates.

And that is it. The way the code is set up now, it will call the slow-response route and as a result, the fetch request will be aborted after 2 seconds. If you want to also test out the success flow, change the URL to the following:

const url = "https://simple-node-server.vercel.app/";
Enter fullscreen mode Exit fullscreen mode

As mentioned earlier, this API is available both in the browser and in Node.js. In Node.js, the following core APIs support the AbortController API, fs, net, http, events, child_process, readline, and stream.

NOTE: You can also play around with a live example in this Codepen.


Check out SAML Jackson if you are building a SaaS and need to add support for single sign-on (SSO), SAML, and DirectorySync.


Cancelling a previous request onchange

But wait, I mentioned a second scenario earlier, so let’s take a look at how it will work. First, the code:

HTML

<form id="weather-data" name="weather" action="" method="get">
  <label for="city">Select a city</label>
  <select id="city" name="city">
    <option value="london">London</option>
    <option value="birmingham">Birmingham</option>
    <option value="cambridge">Cambridge</option>
    <option value="sheffield">Sheffield</option>
  </select>
  <button type="submit">Get weather</button>
</form>
Enter fullscreen mode Exit fullscreen mode

The HTML is a straightforward HTML form element with a select dropdown and a button element to submit the form.

JavaScrpt

const citySelector = document.getElementById("city");
const weatherForm = document.getElementById("weather-data");

let abortController;
let lastSelectedCity;

citySelector.addEventListener("change", (event) => {
  console.log(`Weather data request for ${lastSelectedCity} aborted.`);
  console.log("New selected city is: ", citySelector.value);
  abortController.abort();
});

weatherForm.addEventListener("submit", (event) => {
  event.preventDefault();

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

  const baseURL = "https://simple-node-server.vercel.app/weather";

  const formData = new FormData(weatherForm);
  const city = formData.get("city");
  lastSelectedCity = city;

  fetch(`${baseURL}/?city=${city}`, { signal })
    .then((response) => {
      if (!response.ok) {
        throw new Error(`HTTP error: ${response.status}`);
      }

      return response.json();
    })
    .then((json) => {
      console.log(JSON.stringify(json));
    })
    .catch((err) => {
      if (err.name === "AbortError") {
        console.info("Cancelled previous fetch request");
      } else {
        // if it was not an AbortError, throw the error so it propagates
        throw err;
      }
    });
});
Enter fullscreen mode Exit fullscreen mode

The JavaScript is a bit more involved though so, let’s break it down.

const citySelector = document.getElementById("city");
const weatherForm = document.getElementById("weather-data");

let abortController;
let lastSelectedCity;
Enter fullscreen mode Exit fullscreen mode

We start by getting a reference to the city dropdown and the weather-data form. Next, we set up two variables we will need a bit later. The one will store our AbortController and the other the last selected city.

citySelector.addEventListener("change", (event) => {
  console.log(`Weather data request for ${lastSelectedCity} aborted.`);
  console.log("New selected city is: ", citySelector.value);
  abortController.abort();
});
Enter fullscreen mode Exit fullscreen mode

We add an onchange event listener to the city dropdown. This will allow us to cancel the previous request if the user changes the selected city. We give ourselves a little context of what is happening by logging out of the last selected city we are canceling the request for and then logging the new city we are requesting data for.

weatherForm.addEventListener("submit", (event) => {
  event.preventDefault();

  abortController = new AbortController();
  const { signal } = abortController;
Enter fullscreen mode Exit fullscreen mode

Some of the above will look familiar to you. We register an event listener on the weather-data form and prevent the default behavior of the form from being submitted when the button is clicked. We then create a new AbortController and store the reference in the variable we created earlier. Lastly, we get a reference to the signal property as before.

const baseURL = "https://simple-node-server.vercel.app/weather";

const formData = new FormData(weatherForm);
const city = formData.get("city");
lastSelectedCity = city;
Enter fullscreen mode Exit fullscreen mode

We store the base URL we will call to get our weather data. This uses a new route I added to the simple Nodejs server I mentioned before. The endpoint will call the WeatherDB API for the city we pass as a query parameter. The endpoint also takes a delay query parameter that we will use to simulate a slow response.

We next use the super useful FormData API to construct a set of key/value pairs representing the form fields. From this, we use the get method on FormData to get the value of the city field. For reference, we store the selected city as the last selected city which we will use in our change event listener.

fetch(`${baseURL}/?city=${city}`, { signal })
  .then((response) => {
    if (!response.ok) {
      throw new Error(`HTTP error: ${response.status}`);
    }

    return response.json();
  })
  .then((json) => {
    console.log(JSON.stringify(json));
  })
  .catch((err) => {
    if (err.name === "AbortError") {
      console.info("Cancelled previous fetch request");
    } else {
      // if it was not an AbortError, throw the error so it propagates
      throw err;
    }
  });
Enter fullscreen mode Exit fullscreen mode

This should look very familiar as it is essentially the same thing we did in the earlier example. We make our request, and if all goes well, we log out the JSON response we get back from the server.

{
  "region": "London, UK",
  "currentConditions": {
    "dayhour": "Saturday 6:00 PM",
    "temp": { "c": 7, "f": 44 },
    "precip": "16%",
    "humidity": "81%",
    "wind": { "km": 14, "mile": 9 },
    "iconURL": "https://ssl.gstatic.com/onebox/weather/64/cloudy.png",
    "comment": "Cloudy"
  }
}
Enter fullscreen mode Exit fullscreen mode

The way we are currently calling the endpoint should return pretty quickly so, we cannot test if our abort code works. To test this, update the following line as follows:

fetch(`${baseURL}/?city=${city}&delay=5000`, { signal });
Enter fullscreen mode Exit fullscreen mode

Go ahead and click on the "Get weather" button, then change the city and click the "Get weather" button again. You should see output similar to the following.

"Weather data request for london aborted."

"New selected city is: " "birmingham"

"Cancelled previous fetch request"

"{'region':'Birmingham, AL','currentConditions':{'dayhour':'Saturday 12:00 PM','temp':{'c':26,'f':78},'precip':'0%','humidity':'37%','wind':{'km':21,'mile':13},'iconURL':'https://ssl.gstatic.com/onebox/weather/64/fog.png','comment':'Haze'}}"
Enter fullscreen mode Exit fullscreen mode

Neat! Instead of waiting for the previous request to complete, we simply canceled it and requested the new selected city. Again, you can experiment with the code in this example on Codepen.

I hope you found this interesting and useful. Until next time, keep building an open web, accessible to all.

Top comments (0)