Cancelling asynchronous operations with AbortController
How to cancel asynchronous operations with the AbortController API.
Published on: 2022-07-01
Written by Schalk Neethling
I have worked on a few projects where client side fetching of data was required. One of the challenges we ran into a number of times, is what to do about a request if the user navigates away from the current view. Turns out there is actually a well supported Web API to do just that. This API is available in the browser and in 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 the purposes of our discussion here though, let’s create two possible scenarios.
- The response from the server hangs for a long time
- 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;
}
});
That is a lot of code. Let’s break it down.
For the purposes of 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 aborted the request.
<p id="output"></p>
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);
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");
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();
});
We first ensure that our request was successful and throw an Error
if it wasn’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 theResponse
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);
});
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;
}
});
Things do not always go as planned though and 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/";
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.
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>
The HTML is a straight forward HTML form
element with a select
dropdown and a button
element to submit the form.
JavaScript
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;
}
});
});
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;
We start by getting a reference to the city
dropdown and the weather-data
form. Next we setup 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();
});
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 the last selected city we are cancelling the request for, and then log the new city we are requesting data for.
weatherForm.addEventListener("submit", (event) => {
event.preventDefault();
abortController = new AbortController();
const { signal } = abortController;
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 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;
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;
}
});
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"
}
}
The way we are currently calling the endpoint should return pretty quickly and 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 });
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'}}"
Neat! Instead of waiting for the previous request to complete, we simply cancelled it and made a request for 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 by all.