Request Timeouts With the Fetch API


I’m a big fan of the Fetch API. I use it regularly in all sorts of projects, including this site and the API that powers the stats on the about page. However it isn’t always as clear how to do things like error handling and request timeouts as it is in libraries like Axios.

If you’re not familiar with fetch, it’s a native API that massively simplifies making AJAX requests compared to the older XHR method, and it’s supported in all modern browsers. When it initally landed, however, there was no easy way to handle request timeouts. You could fake it with Promise.race or by wrapping your fetch in another Promise, but these solutions don’t actually cancel the request. This is where AbortController comes in.

AbortController is an API that, much like its name and my previous sentence suggests, allows us to abort (cancel) requests. Though browser support isn’t wonderful at time of writing, it can be used in most modern browsers and polyfills are available. The API itself has a very small surface area: a signal property to attach to request objects, and an abort method to actually cancel the request. Because the API is so simple, it’s very flexible — Jake Archibald has a fairly in-depth article on the Google Developers blog going over various cancellation scenarios, as well as the history behind the API, and I highly recommend giving it a read.

With AbortController, it becomes trivial to cancel a request if it doesn’t resolve before a given period of time: if the abort method is called before the request resolves (or before the response Body is consumed), the request is cancelled; if it’s called after, the browser just ignores the call. To put it all together, we need to:

  1. Create an instance of AbortController
  2. Create a setTimeout function that calls the controller’s abort method
  3. Pass the controller’s signal to fetch’s options object

Putting It All Together

First, because we’re basically writing a shim around fetch, I’m going to add an extra little perk. If the response doesn’t return in the 200 range (that is, if response.ok evaluates to false), we’re going to throw an error. We absolutely do not need to do this — we could just catch our timeout and the function would work the same (we actually don’t even need to do that). However, I always perform this check anyways, so this removes a lot of boilerplate code for me.

Anyways, here is my generic fetchWithTimeout function. It should work in any environment that supports fetch and AbortController.

1const fetchWithTimeout = (uri, options = {}, time = 5000) => {
2 // Lets set up our `AbortController`, and create a request options object
3 // that includes the controller's `signal` to pass to `fetch`.
4 const controller = new AbortController()
5 const config = { ...options, signal: controller.signal }
6
7 // Set a timeout limit for the request using `setTimeout`. If the body of this
8 // timeout is reached before the request is completed, it will be cancelled.
9 const timeout = setTimeout(() => {
10 controller.abort()
11 }, time)
12
13 return fetch(uri, config)
14 .then(response => {
15 // Because _any_ response is considered a success to `fetch`,
16 // we need to manually check that the response is in the 200 range.
17 // This is typically how I handle that.
18 if (!response.ok) {
19 throw new Error(`${response.status}: ${response.statusText}`)
20 }
21
22 return response
23 })
24 .catch(error => {
25 // When we abort our `fetch`, the controller conveniently throws a named
26 // error, allowing us to handle them seperately from other errors.
27 if (error.name === 'AbortError') {
28 throw new Error('Response timed out')
29 }
30
31 throw new Error(error.message)
32 })
33}

Using the function is fairly straightforward. Because we return fetch directly, we can use it in much the same way; the only change should be the addition of a third paramater (our time argument) and the extra error handling we discussed above.

1// This example _always_ logs the error, because I'm telling httpstat.us to wait
2// at least 1s before responding, but setting the timeout threshold to 500ms.
3// Also, this could definitely be written in async/await if you preferred.
4fetchWithTimeout(
5 'https://httpstat.us/200?sleep=1000',
6 { headers: { Accept: 'application/json' } },
7 500
8)
9 .then(response => response.json())
10 .then(json => {
11 console.log(`This will never log out: ${json}`)
12 })
13 .catch(error => {
14 console.error(error.message)
15 })

That’s it. That’s the whole post. Though the snippet is ultimately pretty simple (it’s 20 lines without whitespace and comments) writing this provided me with three major benefits: it forced me to abstract the function to the most reusable version I could, it gave me an opportunity to research AbortController to make sure I knew exactly how it behaved, and it provided a place where I can come find this snippet in the future instead of rooting through old projects.