Adding timeout and multiple abort signals to fetch() (TypeScript/React)
Tags: javascript, typescript, tutorial, react
Published: 2023-04-29 10:56:44


## Table of contents

- [Timeout](#timeout)
  - [1. Using setTimeout()](#using-settimeout)
  - [2. Using AbortController.timeout()](#using-abortcontroller-timeout)
- [The Problem with only one abort signal](#the-problem-with-only-one-abort-signal)
  - [Using fetch() in React](#using-fetch-in-react)
- [Combining two abort signals](#combining-two-abort-signals)
  - [1. Using setTimeout()](#using-settimeout-2)
  - [2. Using AbortController.timeout()](#using-abortcontroller-timeout-2)
- [Adding more signals](#adding-more-signals)
  - [1. Using setTimeout()](#using-settimeout-3)
  - [2. Using AbortController.timeout()](#using-abortcontroller-timeout-3)
- [Adding multiple signals to Axios](#adding-multiple-signals-to-axios)
- [NPM Packages](#npm-packages)
- [Resources](#resources)
- [Credits](#credits)
- [P.S.](#p-s)

## Timeout <a name="timeout"></a>

In my latest project, I made a Node/Express API backend which acted as a proxy between multiple public APIs and my frontend. after deploying it to [Vercel](https://vercel.com/), I encountered this error: `The Serverless Function has timed out.`
This happens when one of the upstream APIs takes too long to respond but could also happen with slow database connections.
Vercel has [a guide on this error](https://vercel.com/guides/what-can-i-do-about-vercel-serverless-functions-timing-out) that explains why it happens and that your backend should return a response to the client if the upstream service takes too long and not wait forever for the response.

I used the [*Fetch API* ](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) to pull data from upstream APIs in my project so I decided to remedy the problem by adding timeout to my fetch requests.

>ℹ️ _**Note:**_ the default timeout for a [`fetch()`](https://developer.mozilla.org/en-US/docs/Web/API/fetch) request is determined by the browser/environment and can't be changed.

There are two main ways to do this:

### 1. Using [*setTimeout()*](https://developer.mozilla.org/en-US/docs/Web/API/setTimeout) <a name="using-settimeout"></a>

We can abort a `fetch()` request by providing it with an [*AbortController*](https://developer.mozilla.org/en-US/docs/Web/API/AbortController) [*signal*](https://developer.mozilla.org/en-US/docs/Web/API/AbortController/signal):
```typescript
const myFunction = async () => {
  const controller = new AbortController();
  const res = await fetch('url', { signal: controller.signal });
};
```
Then we can call the [*controller.abort()*](https://developer.mozilla.org/en-US/docs/Web/API/AbortController/abort) method to abort the request.
``` typescript
const reason = new DOMException('message', 'name');
controller.abort(reason);
```
So the only thing left to do is to use [*setTimeout()*](https://developer.mozilla.org/en-US/docs/Web/API/setTimeout) to call `controller.abort()` after a set period of time.
```typescript
const timeoutId = setTimeout(() => controller.abort(), timeout);
...
clearTimeout(timeoutId);
```
> ⚠️ _**Note:**_ You should always use [*clearTimeout()*](https://developer.mozilla.org/en-US/docs/Web/API/clearTimeout) to cancel your `setTimeout()`, otherwise it'll continue running in the background!

After putting it together the function looks like this:
```javascript
const fetchTimeout = async (input, init = {}) => {
  const timeout = 5000; // 5 seconds
  const controller = new AbortController();
  const reason = new DOMException('signal timed out', 'TimeoutError');
  const timeoutId = setTimeout(() => controller.abort(reason), timeout);
  const res = await fetch(input, {
    ...init,
    signal: controller.signal,
  });
  clearTimeout(timeoutId);
  return res;
};
```
> ⚠️ There's a bug! if `fetch()` throws an error, the next line (`clearTimeout()`) won't run and `setTimeout()` will continue running in the background.

To fix it we can use [*try...catch*](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/try...catch) to clear the timeout when an error happens. 
```typescript
let res;
try {
  res = await fetch(input, {
    ...init,
    signal: controller.signal,
  });
} catch (error) {
  clearTimeout(timeoutId);
  throw error;
}
```

We can further improve it by extending `RequestInit` and adding `timeout`, so we can call it using: `fetchTimeout('...', { timeout: 5000 });`. And because we have to `clearTimeout()` whether there's an error or not, we can put it in `finally{}` instead.
The final function (with types added):
```typescript
interface RequestInitTimeout extends RequestInit {
  timeout?: number;
}

const fetchTimeout = async (
  input: RequestInfo | URL,
  initWithTimeout?: RequestInitTimeout
) => {
  // if no options are provided, do regular fetch
  if (!initWithTimeout) return await fetch(input);
  const { timeout, ...init } = initWithTimeout;
  // if no timeout is provided, do regular fetch with options
  if (!timeout) return await fetch(input, init);
  // else
  const controller = new AbortController();
  const reason = new DOMException(
    `signal timed out (${timeout}ms)`,
    'TimeoutError'
  );
  const timeoutId = setTimeout(() => controller.abort(reason), timeout);
  let res;
  try {
    res = await fetch(input, {
      ...init,
      signal: controller.signal,
    });
  } finally {
    clearTimeout(timeoutId);
  }
  return res;
};
```
>ℹ️ _**Note:**_ In an [*async*](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function) function, returning a value acts as [*resolve*](https://developer.mozilla.org/en-US/docs/web/javascript/reference/global_objects/promise/resolve) and throwing an error acts as [*reject*](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/reject) which means we can use [*.then()*](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/then), [*.catch()*](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/catch), and [*.finally()*](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/finally) with this function as well. 

### 2. Using [*AbortController.timeout()*](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout) <a name="using-abortcontroller-timeout"></a>
There's a newer and easier way of achieving this using [*AbortController.timeout()*](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout) which will give you a signal that will automatically `abort()` after the set timeout that you can pass to `fetch()`:
```typescript
const myFunction = async () => {
  const signal = AbortSignal.timeout(5000); // 5 seconds
  const res = await fetch('url', { signal });
}
```
> ℹ️ _**Note:**_ `{ signal }` is the shorthand for `{ signal: signal }`

We can modify the `fetchTimeout()` function to use `AbortController.timeout()` instead:
```typescript
interface RequestInitTimeout extends RequestInit {
  timeout?: number;
}

const fetchTimeout = async (
  input: RequestInfo | URL,
  initWithTimeout?: RequestInitTimeout
) => {
  // if no options are provided, do regular fetch
  if (!initWithTimeout) return await fetch(input);
  const { timeout, ...init } = initWithTimeout;
  // if no timeout is provided, do regular fetch with options
  if (!timeout) return await fetch(input, init);
  // else
  const signal = AbortSignal.timeout(timeout);
  const res = await fetch(input, {
    ...init,
    signal,
  });
  return res;
};
```
>ℹ️ `AbortSignal.timeout()` gives us a few advantages:
- We don't have to specify an error message as an appropriate *`TimeoutError`* is thrown by default
- We don't have to use *`setTimeout()`* and *`clearTimeout()`*
- We don't need to use *`try...catch`*

## The Problem with only one abort signal <a name="the-problem-with-only-one-abort-signal"></a>
After writing the above functions and using them in my Node/Express API, in case of a fetch timeout, I could return an error like: `selected API took too long to respond.` and I didn't get the `The Serverless Function has timed out.` error anymore.
Now I wanted to use the same function in my frontend [*React*](https://react.dev/) app as well and show an error in case of timeout (and refetch after a while), instead of waiting for the `fetch()` forever. but I encountered a problem.
### Using `fetch()` in React <a name="using-fetch-in-react"></a>
To run a `fetch()` request in React we use the [*useEffect()*](https://react.dev/reference/react/useEffect) hook so we can run it only once when the component mounts or when a dependency changes instead of re-fetching on every component render:
> ⚠️ _**Note:**_ The wrong way
```typescript
useEffect(() => {
  const getData = async () => {
    const res = await fetch('url');
    ...
  };
  getData();
}, []);
```
But there's a big problem with this code. when our component unmounts, the async function / fetch request continues to run. it may not be obvious in small applications but it causes many problems:
- If the component mounts/unmounts 100 times, we'll have 100 concurrent fetch requests running!
- If the component unmounts and a new component is showing, the logic in the old component's async function will still run and update the state/data!
- An older fetch request may take longer to complete than the newer one due to network conditions and will update our data/state to the old values!


To fix the problem we have to run a clean-up function and abort the fetch request on component unmount. we can do it by returning a function in the `useEffect()` hook:

> ✅ _**Note:**_ The correct way
```typescript
useEffect(() => {
  const getData = async (signal: AbortSignal) => {
    const res = await fetch('url', { signal });
    ...
  };
  const controller = new AbortController();
  const signal = controller.signal;
  const reason = new DOMException('cleaning up', 'AbortError');
  getData(signal);
  return () => controller.abort(reason);
}, []);
```
>ℹ️ _**Note:**_ You can use `controller.abort()` without providing `reason` but it can be helpful for debugging.

And here's where we encounter the problem. in the `fetchTimeout()` function, we use either a signal we created ourselves or the `AbortSignal.timeout()` signal to abort the fetch request on a timeout. but to abort the request on component unmount as well, we need a second signal, and according to [MDN](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout):

>"At time of writing there is no way to combine multiple signals. This means that you can't directly abort a download using either a timeout signal or by calling AbortController.abort(). "

So..., let's do exactly that!

## Combining two abort signals <a name="combining-two-abort-signals"></a>
To add another signal to our function, we need to make it so the 
`abort()` method of the second signal triggers the `abort()` method of our timeout signal. we can achieve this by adding an `abort` event listener to the second signal:
```typescript
const controller = new AbortController();
secondSignal.addEventListener(
  'abort',
  () => {
    controller.abort(secondSignal.reason);
  },
  { signal: controller.signal }
);
```
>ℹ️ _**Note:**_ When we pass a signal in the options (3rd parameter) of the [`addEventListener()`](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener) function, the event is removed when the signal is aborted.

The modified functions would look like this:
### 1. Using *setTimeout()* <a name="using-settimeout-2"></a>
```typescript
interface RequestInitTimeout extends RequestInit {
  timeout?: number;
}

const fetchTimeout = async (
  input: RequestInfo | URL,
  initWithTimeout?: RequestInitTimeout
) => {
  // if no options are provided, do regular fetch
  if (!initWithTimeout) return await fetch(input);
  const { timeout, ...init } = initWithTimeout;
  // if no timeout is provided, do regular fetch with options
  if (!timeout) return await fetch(input, init);
  // else
  const controller = new AbortController();
  const reason = new DOMException('signal timed out', 'TimeoutError');
  const timeoutId = setTimeout(() => controller.abort(reason), timeout);
  const signal = init.signal;
  // if fetch has a signal
  if (signal) {
    // if signal is already aborted, abort timeout signal
    if (signal.aborted) controller.abort(signal.reason);
    // else add on signal abort: abort timeout signal
    else
      signal.addEventListener(
        'abort',
        () => {
          controller.abort(signal.reason);
          clearTimeout(timeoutId);
        },
        { signal: controller.signal }
      );
  }
  let res;
  try {
    res = await fetch(input, {
      ...init,
      signal: controller.signal,
    });
  } finally {
    clearTimeout(timeoutId);
  }
  return res;
};
```
#### 2. Using *AbortController.timeout()* <a name="using-abortcontroller-timeout-2"></a>

Because we can't manually `abort()` the `AbortController.timeout()` signal, we will need a third signal. then we add event listeners to both the input signal and the timeout signal to `abort()` the third signal:
```typescript
interface RequestInitTimeout extends RequestInit {
  timeout?: number;
}

const fetchTimeout = async (
  input: RequestInfo | URL,
  initWithTimeout?: RequestInitTimeout
) => {
  // if no options are provided, do regular fetch
  if (!initWithTimeout) return await fetch(input);
  const { timeout, ...init } = initWithTimeout;
  // if no timeout is provided, do regular fetch with options
  if (!timeout) return await fetch(input, init);
  // else
  const timeoutSignal = AbortSignal.timeout(timeout);
  let controller: AbortController;
  let thirdSignal: AbortSignal;
  // input signal
  const inputSignal = init.signal;
  // if fetch has a signal
  if (inputSignal) {
    // third signal setup
    controller = new AbortController();
    thirdSignal = controller.signal;
    timeoutSignal.addEventListener(
      'abort',
      () => {
        controller.abort(timeoutSignal.reason);
      },
      { signal: thirdSignal }
    );
    // if input signal is already aborted, abort third signal
    if (inputSignal.aborted) controller.abort(inputSignal.reason);
    // else add on signal abort: abort third signal
    else
      inputSignal.addEventListener(
        'abort',
        () => {
          controller.abort(inputSignal.reason);
        },
        { signal: thirdSignal }
      );
  }
  return await fetch(input, {
    ...init,
    signal: inputSignal ? thirdSignal! : timeoutSignal,
  });
};
```
> ℹ️ _**Note:**_ The reason I've not used `signal.onabort` instead of `signal.addEventListener()` is that then we would need to iterate through all of the signals and remove it once the new signal is aborted. but providing the new signal to `addEventListener()` saves us from doing that.

## Adding more signals <a name="adding-more-signals"></a>
We can do what we did for merging two signals for any number of signals. the modified functions and interface that accept a `signals` array and abort when any one of the signals is aborted:
### 1. Using *setTimeout()* <a name="using-settimeout-3"></a>
```typescript
interface RequestInitMS extends RequestInit {
  timeout?: number;
  signals?: Array<AbortSignal>;
}

const fetchMS = async (
  input: RequestInfo | URL,
  initMS?: RequestInitMS
) => {
  // if no options are provided, do regular fetch
  if (!initMS) return await fetch(input);
  let { timeout, signals, ...init } = initMS;
  // if no timeout or signals is provided, do regular fetch with options
  if (!timeout && !signals) return await fetch(input, init);
  signals ||= [];
  // if signal is empty and signals only has one item,
  // set signal to it and do regular fetch
  if (signals.length === 1 && !init.signal)
    return await fetch(input, { ...init, signal: signals[0] });
  // if signal is set, push to signals array
  init.signal && signals.push(init.signal);
  const controller = new AbortController();
  // timeout setup
  let timeoutId: ReturnType<typeof setTimeout>;
  if (timeout) {
    const reason = new DOMException(
      `signal timed out (${timeout}ms)`,
      'TimeoutError'
    );
    timeoutId = setTimeout(() => controller.abort(reason), timeout);
  }
  // add event listener
  for (let i = 0, len = signals.length; i < len; i++) {
    // if signal is already aborted, abort timeout signal
    if (signals[i].aborted) {
      controller.abort(signals[i].reason);
      break;
    }
    // else add on signal abort: abort timeout signal
    signals[i].addEventListener(
      'abort',
      () => {
        controller.abort(signals![i].reason);
        timeout && clearTimeout(timeoutId);
      },
      { signal: controller.signal }
    );
  }
  let res;
  try {
    res = await fetch(input, {
      ...init,
      signal: controller.signal,
    });
  } finally {
    timeout && clearTimeout(timeoutId!);
  }
  return res;
};
```
> ℹ️ _**Note:**_ In browser, `setTimeout()` returns `number` but in Node.js, it returns `NodeJS.Timeout`. setting the type of `timeoutId` to `ReturnType<typeof setTimeout>`, sets it's type to whatever the return type of `setTimeout()` is at that moment and allows the code to run in both environments.

#### 2. Using *AbortController.timeout()* <a name="using-abortcontroller-timeout-3"></a>
```typescript
interface RequestInitMS extends RequestInit {
  timeout?: number;
  signals?: Array<AbortSignal>;
}

const fetchMS = async (
  input: RequestInfo | URL,
  initMS?: RequestInitMS
) => {
  // if no options are provided, do regular fetch
  if (!initMS) return await fetch(input);
  let { timeout, signals, ...init } = initMS;
  // if no timeout or signals is provided, do regular fetch with options
  if (!timeout && !signals) return await fetch(input, init);
  signals ||= [];
  // if signal is empty and signals only has one item,
  // set signal to it and do regular fetch
  if (signals.length === 1 && !init.signal)
    return await fetch(input, { ...init, signal: signals[0] });
  // if signal is set, push to signals array
  init.signal && signals.push(init.signal);
  const controller = new AbortController();
  // timeout setup
  if (timeout) {
    const timeoutSignal = AbortSignal.timeout(timeout);
    signals.push(timeoutSignal);
  }
  // add event listener
  for (let i = 0, len = signals.length; i < len; i++) {
    // if signal is already aborted, abort timeout signal
    if (signals[i].aborted) {
      controller.abort(signals[i].reason);
      break;
    }
    // else add on signal abort: abort timeout signal
    signals[i].addEventListener(
      'abort',
      () => {
        controller.abort(signals![i].reason);
      },
      { signal: controller.signal }
    );
  }
  return await fetch(input, {
    ...init,
    signal: controller.signal,
  });
};
```
## Adding multiple signals to Axios <a name="adding-multiple-signals-to-axios"></a>
[*Axios*](https://axios-http.com/docs/cancellation) already has a `timeout` built-in and can be aborted using an `AbortSignal` as well. so in a situation like the mentioned `useEffect()`, it should work without any modifications:
```typescript
useEffect(() => {
  const getData = async (signal: AbortSignal) => {
    const res = await axios.get('url', { timeout: 5000, signal });
    ...
  };
  const controller = new AbortController();
  const signal = controller.signal;
  const reason = new DOMException('cleaning up', 'AbortError');
  getData(signal);
  return () => controller.abort(reason);
}, []);
```
But if for any reason you need more signals, I've made a utility function that takes multiple signals as input and returns a signal that will abort when any of the input signals are aborted:
```typescript
const multiSignal = (...inputSignals: AbortSignal[] | [AbortSignal[]]) => {
  const signals = Array.isArray(inputSignals[0])
    ? inputSignals[0]
    : (inputSignals as AbortSignal[]);
  // if only one signal is provided, return it
  const len = signals.length;
  if (len === 1) return signals[0];
  // new signal setup
  const controller = new AbortController();
  const signal = controller.signal;
  // add event listener
  for (let i = 0; i < len; i++) {
    // if signal is already aborted, abort new signal
    if (signals[i].aborted) {
      controller.abort(signals[i].reason);
      break;
    }
    // else add on signal abort: abort new signal
    signals[i].addEventListener(
      'abort',
      () => {
        controller.abort(signals[i].reason);
      },
      { signal }
    );
  }
  return signal;
};
```
>ℹ️ _**Note:**_ `(...inputSignals)` allows us to access all of the function's arguments with the `inputSignals` variable, which in this case is either a [*tuple*](https://www.typescriptlang.org/docs/handbook/basic-types.html#tuple) of an `AbortSignal` array or an `AbortSignal` array itself. this allows us to call the function with multiple signal arguments: `multiSignal(s1, s2, s3)` as well an array of signals: `multiSignal([s1,s2,s3])`.

You can add multiple signals to Axios using it:
```typescript
const controller1 = new AbortController();
const controller2 = new AbortController();
const res = await axios.get('url', {
  signal: multiSignal(controller1.signal, controller2.signal),
  timeout: 5000,
});
```
Also, If you don't need the `timeout` option in the `fetchTimeout()` function we made above, you can use `AbortSignal.timeout()` along with `multiSignal()` to achieve the same result with `fetch()`:
```typescript
const controller = new AbortController();
const signal = controller.signal;
const timeoutSignal = AbortSignal.timeout(5000);
const res = await fetch('url', {
  signal: multiSignal(signal, timeoutSignal),
});
```
>ℹ️ _**Note:**_ `multiSignal()` can be used in any function that accepts an `AbortSignal()`.

`fetchMS()` function can be simplified using `multiSignal()`:
```typescript
import multiSignal from 'multi-signal';

interface RequestInitMS extends RequestInit {
  timeout?: number;
  signals?: Array<AbortSignal>;
}

const fetchMS = async (input: RequestInfo | URL, initMS?: RequestInitMS) => {
  // if no options are provided, do regular fetch
  if (!initMS) return await fetch(input);
  let { timeout, signals, ...init } = initMS;
  // if no timeout or signals is provided, do regular fetch with options
  if (!timeout && !signals) return await fetch(input, init);
  signals ||= [];
  // if signal is empty and signals only has one item,
  // set signal to it and do regular fetch
  if (signals.length === 1 && !init.signal)
    return await fetch(input, { ...init, signal: signals[0] });
  // if signal is set, push to signals array
  init.signal && signals.push(init.signal);
  // timeout setup
  if (timeout) {
    const timeoutSignal = AbortSignal.timeout(timeout);
    signals.push(timeoutSignal);
  }
  return await fetch(input, {
    ...init,
    signal: multiSignal(signals),
  });
};
```

## NPM Packages <a name="npm-packages"></a>
Finally, I've published the `fetchMS()` and `multiSignal()` as  packages on [*NPM*](https://npmjs.com) so you can install and use them easily. Check the package readme for more information:

`fetchMS()` : [fetch-multi-signal](https://www.npmjs.com/package/fetch-multi-signal)
`multiSignal()`: [multi-signal](https://www.npmjs.com/package/multi-signal)

## Resources <a name="resources"></a>
- [Proposal: fetch with multiple AbortSignals](https://github.com/whatwg/fetch/issues/905) - I got the idea of merging multiple signals from here.
- [any-signal](https://www.npmjs.com/package/any-signal) - After writing this post, I noticed that there's already a package that does what `multiSignal()` does. I suggest using that package in production instead, as I'm just doing this for learning purposes and my implementation could be buggy/incomplete.

### Credits <a name="credits"></a>
Cover photo by <a href="https://unsplash.com/@othentikisra">Israel Palacio</a> on <a href="https://unsplash.com/">Unsplash</a>

### P.S. <a name="p-s"></a>

![ChatGPT](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ljpy4gcuu3lnyxv2kppg.png)

Edit:
I just noticed you can abort a fetch request in `useEffect()` with only one signal and `setTimeout()`. you just need to remember to use `clearTimeout()`.
```typescript
useEffect(() => {
  const getData = async (
    signal: AbortSignal,
    timeoutId: ReturnType<typeof setTimeout>
  ) => {
    try {
      const res = await fetch('url', { signal });
      // set data/state
    } catch (err: any) {
      if (err.name === 'timeoutError') console.error('Timeout Error');
      else if (err.name === 'AbortError') console.error('Abort Error');
    } finally {
      clearTimeout(timeoutId);
    }
  };
  const controller = new AbortController();
  const signal = controller.signal;
  const timeoutReason = new DOMException('signal timed out', 'TimeoutError');
  const cleanupReason = new DOMException('cleaning up', 'AbortError');
  const timeoutId = setTimeout(() => controller.abort(timeoutReason), 2000); // 2 sec
  getData(signal, timeoutId);
  return () => controller.abort(cleanupReason);
}, []);
```