How do you cancel an in flight fetch request in JavaScript?
fetch has no built-in timeout. Combine AbortController + setTimeout: create a controller, schedule abort() after N ms, pass signal to fetch, and clear the timer in finally. Always clear the timer to avoid aborting a now-completed request. Wrap into a reusable fetchWithTimeout helper. On modern platforms, AbortSignal.timeout(ms) is a one-liner replacement.
fetch() has no timeout option. To enforce one, combine AbortController with setTimeout.
Basic implementation
async function fetchWithTimeout(url, options = {}, timeoutMs = 8000) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(url, { ...options, signal: controller.signal });
return res;
} finally {
clearTimeout(timer);
}
}Critical: clearTimeout in finally. Otherwise the timer keeps the event loop alive (in Node) and may fire abort on an already-finished request (harmless, but messy).
Handling the abort error
try {
const res = await fetchWithTimeout('/api/data', {}, 5000);
const data = await res.json();
} catch (err) {
if (err.name === 'AbortError') {
// could be timeout OR user-initiated abort — disambiguate if needed
throw new Error('Request timed out');
}
throw err;
}If you need to distinguish "timeout" from "user cancelled," wrap differently:
async function fetchWithTimeout(url, options = {}, timeoutMs = 8000) {
const controller = new AbortController();
const userSignal = options.signal;
// forward external abort to our controller
if (userSignal) {
if (userSignal.aborted) controller.abort();
else userSignal.addEventListener('abort', () => controller.abort());
}
let timedOut = false;
const timer = setTimeout(() => { timedOut = true; controller.abort(); }, timeoutMs);
try {
return await fetch(url, { ...options, signal: controller.signal });
} catch (err) {
if (timedOut) throw new Error(`Timeout after ${timeoutMs}ms`);
throw err;
} finally {
clearTimeout(timer);
}
}Modern one-liner: AbortSignal.timeout
Chrome 103+, Firefox 100+, Safari 16+, Node 17.3+:
const res = await fetch(url, { signal: AbortSignal.timeout(8000) });It rejects with a TimeoutError (DOMException) — distinguishable by err.name === 'TimeoutError'.
To combine timeout with an external user-cancellable signal, use AbortSignal.any (Chrome 116+):
const res = await fetch(url, {
signal: AbortSignal.any([userController.signal, AbortSignal.timeout(8000)]),
});Pitfalls
- Forgetting clearTimeout — request finishes at 1s, timer fires at 8s and aborts… a now-completed Response object. The fetch already resolved, but the abort logs noise.
- Reading the body after timeout —
res.json()is its own async operation and can hang independently of the request. The signal aborts in-flight, but if you've already received headers and are streaming the body, abort cancels mid-stream. - Retrying without backoff — if your timeout fires from server overload, retrying immediately makes things worse. Pair with exponential backoff + jitter.
- Per-request vs per-resource timeout — a 5s timeout per request is reasonable; for a multi-step flow, set a total budget at the outermost layer.
What "timeout" really means
AbortController aborts the entire request including streaming body read. It does not notify the server you cancelled (TCP RST may eventually); the server keeps doing work. For idempotent reads that's fine. For mutations, design retries to be safe (idempotency keys).
Follow-up questions
- •How does AbortSignal.any compose multiple cancel sources?
- •What's the difference between AbortError and TimeoutError?
- •How do you implement a fetch with both timeout and exponential-backoff retry?
- •What happens server-side when the client aborts a long request?
Common mistakes
- •Forgetting clearTimeout in finally — fires after request completes.
- •Using Promise.race with a timer that doesn't actually cancel the request — fetch keeps running in background.
- •Treating an abort error as a generic network error in retry logic — should not retry user-cancelled requests.
- •Setting a timeout shorter than expected response time and retrying immediately, amplifying load.
- •Forgetting that body read (.json/.text) is a separate phase that can hang after headers arrive.
Performance considerations
- •Timeouts cap tail latency and prevent zombie requests from holding sockets and memory. Without them, a stalled connection can keep a fetch promise pending indefinitely, leaking listeners and (in React) preventing unmounted components from being GCed if you setState on the result. Always set a timeout on every fetch in production code.
Edge cases
- •Aborting before fetch starts (controller.abort() before fetch()) makes fetch reject immediately.
- •External signal forwarding: must support both already-aborted and abort-mid-flight cases.
- •AbortSignal.timeout reuses a single shared timer per page — micro-optimization, but cheaper than rolling your own.
- •For streaming responses (SSE, LLM tokens) the timeout policy is different — you want per-chunk timeout, not request timeout.
- •On HTTP/2 multiplexed connections, abort cancels just the stream, not the connection.
Real-world examples
- •Fetch in service workers needs a tight timeout so the SW doesn't get killed waiting on a slow origin.
- •Search-as-you-type: 2–3s timeout per query, cancel previous on new keystroke.
- •Critical API calls: 5–10s timeout with one retry on TimeoutError (idempotent only).