Fetch with Timeout (AbortController)
Stop waiting for slow requests: abort fetch() after a time limit, support a cancel button, and tell the two apart.
/**
* fetch() that gives up after `timeout` ms.
* Also accepts your own AbortSignal (e.g. a "Cancel" button).
*/
export async function fetchWithTimeout(resource, { timeout = 8000, signal, ...options } = {}) {
const controller = new AbortController();
const timer = setTimeout(() => {
controller.abort(new DOMException("Request timed out after " + timeout + " ms", "TimeoutError"));
}, timeout);
// Forward an outside signal into our controller
const forwardAbort = () => controller.abort(signal.reason);
if (signal) {
if (signal.aborted) forwardAbort();
else signal.addEventListener("abort", forwardAbort, { once: true });
}
try {
return await fetch(resource, { ...options, signal: controller.signal });
} finally {
clearTimeout(timer);
signal?.removeEventListener("abort", forwardAbort);
}
}fetch() has no timeout option. On a bad connection it can wait a very long time before failing. An AbortController gives you a signal you pass to fetch; calling controller.abort() makes that fetch reject immediately.
Line by line
{ timeout = 8000, signal, ...options }pulls out our own options and keeps the rest (method, headers, body) to pass straight tofetch.setTimeoutaborts with aDOMExceptionnamedTimeoutError, so callers can tell "too slow" apart from "the user cancelled" (AbortError).- An outside
signalis forwarded into our controller, so both the timer and your cancel button can stop the same request. finallyalways clears the timer and removes the listener — whether the request succeeded, failed or was aborted — so nothing leaks.
Two things fetch won't do for you
1) A 404 or 500 is not an error for fetch — check response.ok. 2) The timeout here covers waiting for the response headers; reading a large body afterwards (response.json()) isn't covered once the timer is cleared.
This helper
const res = await fetchWithTimeout("/api/items", {
timeout: 5000,
signal: cancelController.signal,
});Built-in shortcut (newer browsers)
const res = await fetch("/api/items", {
signal: AbortSignal.any([
AbortSignal.timeout(5000),
cancelController.signal,
]),
});AbortSignal.timeout() and AbortSignal.any() do the same job natively. AbortSignal.any() is newer (2023–2024), so the helper is the safer choice if you support older browsers.
No live preview for this one: real network requests are switched off in the WANCode preview (see How your code runs), so a timeout can't be demonstrated honestly there. Try it in a local project.
Usage
import { fetchWithTimeout } from "./fetch-with-timeout.js";
async function loadItems() {
try {
const response = await fetchWithTimeout("/api/items", { timeout: 5000 });
if (!response.ok) throw new Error("Server replied " + response.status);
return await response.json();
} catch (error) {
if (error.name === "TimeoutError") {
console.warn("The server is taking too long. Please try again.");
} else if (error.name === "AbortError") {
console.info("Request cancelled.");
} else {
console.error("Couldn't load items:", error.message);
}
return [];
}
}