Adding timeout and multiple abort signals to fetch() (TypeScript/React)

Adding timeout and multiple abort signals to fetch() (TypeScript/React)

Timeout

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, 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 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 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() request is determined by the browser/environment and can't be changed.

There are two main ways to do this:

1. Using setTimeout()

We can abort a fetch() request by providing it with an AbortController signal:

const myFunction = async () => {
  const controller = new AbortController();
  const res = await fetch('url', { signal: controller.signal });
};

Then we can call the controller.abort() method to abort the request.

const reason = new DOMException('message', 'name');
controller.abort(reason);

So the only thing left to do is to use setTimeout() to call controller.abort() after a set period of time.

const timeoutId = setTimeout(() => controller.abort(), timeout);
...
clearTimeout(timeoutId);

⚠️ Note: You should always use clearTimeout() to cancel your setTimeout(), otherwise it'll continue running in the background!

After putting it together the function looks like this:

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 to clear the timeout when an error happens.

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):

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 function, returning a value acts as resolve and throwing an error acts as reject which means we can use .then(), .catch(), and .finally() with this function as well.

2. Using AbortController.timeout()

There's a newer and easier way of achieving this using AbortController.timeout() which will give you a signal that will automatically abort() after the set timeout that you can pass to fetch():

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:

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

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 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

To run a fetch() request in React we use the 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

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

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:

"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

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:

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() function, the event is removed when the signal is aborted.

The modified functions would look like this:

1. Using setTimeout()

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()

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:

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

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()

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()

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

Axios 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:

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:

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 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:

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():

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():

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

Finally, I've published the fetchMS() and multiSignal() as packages on NPM so you can install and use them easily. Check the package readme for more information:

fetchMS() : fetch-multi-signal multiSignal(): multi-signal

Resources

  • Proposal: fetch with multiple AbortSignals - I got the idea of merging multiple signals from here.

  • 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

Cover photo by Israel Palacio on Unsplash

P.S.

ChatGPT

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().

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);
}, []);