86Implement a fetch wrapper with timeout and AbortController
Problem
fetch has no timeout: on a bad network a request can hang for minutes. Implement fetchWithTimeout(url, { timeout = 8000, signal, ...options } = {}):
- It calls
fetch(url, options)and resolves with theResponse. - If the request takes longer than
timeoutms, it aborts the request and rejects with an error whosenameis"TimeoutError". - If the caller passes their own
signaland aborts it, the request is aborted and the promise rejects with that signal's reason (anAbortErrorby default), so callers can tell "cancelled" from "timed out". - It cleans up: the timer is cleared and the listener on the caller's signal is removed when the request finishes, however it finishes.
const controller = new AbortController();
cancelButton.onclick = () => controller.abort();
try {
const res = await fetchWithTimeout("/api/report", { timeout: 5000, signal: controller.signal });
render(await res.json());
} catch (error) {
if (error.name === "TimeoutError") showToast("The server is slow, please retry");
else if (error.name !== "AbortError") throw error;
}Try it yourself
Write your solution, run it, then submit to check it against the tests.
Step by step
Build the solution one idea at a time.
- 1
One controller owns the request
Create an
AbortControllerand pass itssignaltofetch. Both the timeout and the caller's signal abort this one controller, so the request has a single "stop" switch. - 2
Abort with a reason
controller.abort(reason)makesfetchreject withreason. For the timeout, usenew DOMException("Request timed out after 5000 ms", "TimeoutError"), so callers can checkerror.name. - 3
Forward the caller's signal
If the caller's signal is already aborted, abort immediately. Otherwise add an
abortlistener that callscontroller.abort(signal.reason). - 4
Clean up in finally
clearTimeout(timer)andsignal.removeEventListener("abort", onAbort)in afinallyblock. Without this, a long-lived caller signal (for example one per page) collects a listener for every request, and an unfinished timer can abort a request that already succeeded. - 5
Throw the reason you aborted with
If the controller's signal is aborted, rethrow
controller.signal.reason. It is the exactTimeoutErroror caller reason, independent of how a particular browser words its own abort errors.
Approach
Combine two cancellation sources (a timer and an optional external signal) into one AbortController, abort with descriptive reasons, and guarantee cleanup with finally.
Solution
async function fetchWithTimeout(url, { timeout = 8000, signal, ...options } = {}) {
const controller = new AbortController();
const timer = setTimeout(() => {
controller.abort(new DOMException(`Request timed out after ${timeout} ms`, "TimeoutError"));
}, timeout);
const onAbort = () => controller.abort(signal.reason);
if (signal) {
if (signal.aborted) onAbort();
else signal.addEventListener("abort", onAbort, { once: true });
}
try {
return await fetch(url, { ...options, signal: controller.signal });
} catch (error) {
if (controller.signal.aborted) throw controller.signal.reason;
throw error;
} finally {
clearTimeout(timer);
signal?.removeEventListener("abort", onAbort);
}
}Complexity
Time: O(1) overhead per request. Space: O(1).
Edge cases
- A response arriving just before the timeout resolves normally, and the timer is cleared.
- A caller signal that is already aborted rejects without waiting.
- Network errors (
TypeError: Failed to fetch) pass through unchanged. - The timeout covers receiving the headers. Reading a slow body (
res.json()) afterfetchresolves is not covered; see the follow-up.
Follow-up questions
Answer each one out loud first, then reveal the model answer.