In the browser, fetch() has no timeout option. How do you make a fetch request give up after five seconds?
answer
- there is no timeout init option
- the deadline comes from a signal
- one static does timer and controller
- clock starts when the signal is made
- wall-clock for the whole exchange
basics
~10 sDrive it from an abort signal: fetch(url, { signal: AbortSignal.timeout(5000) }) aborts the request when the timer fires. The manual equivalent is an AbortController plus setTimeout calling controller.abort(), with the timer cleared afterwards.
solid answer
~50 s`fetch()` takes no timeout, so the deadline comes from an `AbortSignal`. The one-liner is `fetch(url, { signal: AbortSignal.timeout(5000) })` — widely available in browsers since 2022 — and the browser tears the request down when the timer fires. The manual equivalent, still needed for older targets, is to create an `AbortController`, pass `controller.signal` into the init object, arm `setTimeout(() => controller.abort(), 5000)`, and `clearTimeout` in a `finally` so a fast response does not leave a live timer that aborts nothing. Two things worth saying out loud: the deadline is wall-clock for the whole exchange, not an inactivity timeout — it starts when the signal is created and will cut a slow body off mid-stream — and because `fetch()` resolves at the headers, a timeout that lands after that surfaces on the body stream rather than on the `fetch()` promise.
code
javascript · 19 lines// Modern form: the signal owns both the timer and the controller.
async function getJson(url, ms = 5000) {
const res = await fetch(url, { signal: AbortSignal.timeout(ms) });
if (!res.ok) throw new Error('HTTP ' + res.status);
return res.json();
}
// Manual equivalent for older targets.
async function getJsonManual(url, ms = 5000) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ms);
try {
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) throw new Error('HTTP ' + res.status);
return await res.json();
} finally {
clearTimeout(timer);
}
}go deeper
Remember that fetch() has no timeout option and that a deadline comes from a signal. Be ready to write fetch(url, { signal: AbortSignal.timeout(5000) }) on the spot.
Explain the manual AbortController plus setTimeout form, including clearing the timer in a finally block and why every request needs its own controller rather than a shared one.
Talk about where the deadline actually bites: it is wall-clock over the whole exchange, so a slow body is cut mid-stream, and once the headers have resolved the abort surfaces on the body stream instead of the fetch() promise.
Be ready to argue a timeout policy for a whole client: one shared request helper, separate budgets for reaching the headers versus draining the body, and how deadlines compose with retries so a user is not made to wait the budget several times over.
## Why there is no timeout option `fetch()` is specified in terms of a single, general cancellation primitive rather than a bespoke option per concern. Anything that wants to stop a request — a deadline, a user pressing Cancel, a view being torn down, a newer request superseding this one — goes through the same door: the `signal` init option. So the answer to "how do I add a timeout" is really "how do I abort on a timer". ## The short form ```js const res = await fetch('/api/report', { signal: AbortSignal.timeout(5000) }); ``` `AbortSignal.timeout(ms)` is a static that returns a signal which aborts itself after `ms` milliseconds. There is no controller to hold and no timer to clear — the signal owns both. When it fires, the request is torn down and the abort surfaces as a `DOMException` whose `name` is `TimeoutError`, which is how you tell a deadline apart from a cancel you triggered yourself. One subtlety: the clock starts when the **signal is created**, not when `fetch()` is called. Creating signals up front and using them later burns part of the budget before the request begins, so create the signal at the call site. ## The manual form ```js const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 5000); try { const res = await fetch(url, { signal: controller.signal }); return await res.json(); } finally { clearTimeout(timer); } ``` Three details matter. The `clearTimeout` belongs in a `finally` so it runs on both paths — a stray timer that fires later is harmless for the request but keeps a closure alive and, in a reused-controller design, can abort the wrong thing. The `return await` inside the `try` is deliberate: without `await`, the `finally` runs before the JSON body has been read, and the timer is cleared while the transfer is still in flight. And each request needs its **own** controller: a signal that has aborted stays aborted forever, so a reused controller makes every subsequent `fetch()` reject immediately. ## What the deadline actually covers This is where candidates go wrong. It is not a connect timeout, not a read timeout, and not an inactivity timeout. It is a wall-clock deadline over the entire operation. A response that begins streaming at 200 ms and is still streaming at 5 s will be cut off, mid-body, at the deadline. If you want an inactivity timeout instead — "abort only if nothing has arrived for two seconds" — you have to build it: read the body chunk by chunk and re-arm your own timer on each chunk, aborting the controller when it expires. ## Where the failure appears `fetch()` resolves once the headers are in. If the deadline fires before that, the `fetch()` promise itself rejects. If it fires afterwards, the promise has already settled and the abort lands on the **body**: the response's stream is errored, so a pending `reader.read()`, or the `response.json()` you were awaiting, rejects instead. Code that wraps only the `fetch()` call in a `try` and reads the body outside it will let that rejection escape. ## Race is not cancel A very common wrong answer is to race the fetch against a timer promise. That makes your code stop waiting, but the request keeps running: bytes keep arriving, the connection stays busy, and the response handler may still execute. Only an abort signal actually stops the browser's work. Racing is a control-flow trick; aborting is cancellation. ## Practical shape In real applications the timeout usually belongs in one shared request helper rather than at every call site, because a per-call default is where inconsistency creeps in. It also needs to compose with whatever forwards a caller-supplied signal — a helper that ignores the signal it was handed, and only applies its own deadline, silently removes the caller's ability to cancel.
- What does AbortSignal.timeout(5000) give you that a controller plus setTimeout does not?Less to get wrong. There is no timer handle to clear, no controller to keep alive, and no chance of reusing an already-aborted controller. It also aborts with a `TimeoutError` rather than whatever reason you would have passed, so a deadline is distinguishable from a user-initiated cancel. The tradeoff is that you cannot abort it early yourself — for that you still need your own controller, or a combined signal.
- The deadline fires after the response headers have already arrived. Where does the failure show up?Not on the `fetch()` promise — that resolved when the headers landed. The abort errors the response's body stream, so it surfaces on whatever is reading the body: a pending `reader.read()` rejects, and so does an in-progress `response.json()` or `response.text()`. Code that only guards the `fetch()` call itself will miss it entirely.
- Why is racing fetch against a rejecting timer not a timeout?Because it only stops your code from waiting. The request itself is untouched: the browser keeps downloading, the connection stays occupied, and any work chained to the response still runs. It also hides the failure, since nothing tells the transport to stop. Passing a signal into `fetch()` is what actually cancels the request.
saying these in an interview costs you the question
- Claims fetch() accepts a timeout init option
- Races fetch against a timer and calls it cancelled
- Reuses one AbortController across many requests
- Forgets to clear the timer after a fast response
- Treats an aborted request as a server-side failure