In a page built on XMLHttpRequest, some requests never settle and the spinner stays up forever. How do you bound and cancel an XHR, and which events fire in each case?
answer
- default is zero, meaning no limit
- expiry is its own event
- cancel returns you to nothing
- four ways to finish, one event covers all
- teardown belongs in the last one
basics
~20 sSet xhr.timeout in milliseconds to bound a request and call xhr.abort() to cancel one. A timeout fires the timeout event, an abort fires the abort event, and neither fires load — which is why teardown belongs in loadend, the event that fires on every outcome.
solid answer
~50 s`xhr.timeout` takes milliseconds and defaults to `0`, meaning no limit; set it after `open()` and before `send()`. When it elapses the browser terminates the request and fires `timeout` — **not** `error` — followed by `loadend`. `xhr.abort()` cancels an in-flight request and fires `abort`, then `loadend`; afterwards `xhr.status` reads `0` and the object is back in an unsent state, so there is nothing useful left to read. The spinner bug almost always comes from hiding it in the `load` handler: `load` only fires when a complete HTTP response arrived, so a timeout, an abort or a network `error` leaves the UI stuck. Move teardown to `loadend`, which is the terminal event for every path. Note that `timeout` is a whole-request budget measured from `send()`, not a connect timeout, and that setting it on a synchronous request in a document throws `InvalidAccessError`.
code
javascript · 14 linesfunction request(url, { timeout = 8000 } = {}) {
const xhr = new XMLHttpRequest();
xhr.open('GET', url);
xhr.timeout = timeout;
xhr.addEventListener('load', () => console.log('status', xhr.status));
xhr.addEventListener('error', () => console.log('network failure'));
xhr.addEventListener('timeout', () => console.log('gave up after', timeout));
xhr.addEventListener('abort', () => console.log('cancelled'));
xhr.addEventListener('loadend', () => console.log('always runs'));
xhr.send();
return xhr; // caller can call .abort()
}go deeper
Know that xhr.timeout is set in milliseconds and that xhr.abort() cancels a request. Being able to say that each has its own event, separate from load and error, is what is expected here.
Lay out the four terminal paths — load, error, timeout, abort — and state that loadend follows every one of them, which is why cleanup belongs there. Mention that timeout defaults to 0 meaning unbounded.
Diagnose the stuck spinner from the event table, harden legacy code with a single factory that always sets a timeout and wires all four outcomes, and use abort() to cancel stale in-flight requests such as type-ahead searches.
Own the retry semantics: decide which operations may be retried after a timeout, mandate client-generated idempotency keys for those that create state, and set the timeout budgets consistently against downstream service latency rather than per call site.
## The default is unbounded A fresh `XMLHttpRequest` has `timeout === 0`, which means "no timeout": the request lives until the response completes or the network stack gives up on its own, which can be tens of seconds. A server that accepts the connection and then never answers holds your request — and your spinner — for that whole time. ```js const xhr = new XMLHttpRequest(); xhr.open('GET', '/api/slow'); xhr.timeout = 8000; // ms; after open(), before send() xhr.addEventListener('timeout', () => show('The server took too long.')); xhr.addEventListener('loadend', () => hideSpinner()); xhr.send(); ``` Three properties of `timeout` matter in an interview: - It is a **whole-request** budget, measured from `send()` until the transfer completes. It is not a connect timeout, and it does not reset when bytes arrive — a slow trickle of a large body can exhaust it. - Its expiry fires the `timeout` event, not `error`. Code that only listens for `error` misses it entirely, which is a common source of "nothing happened". - On a synchronous request in a document context, a non-zero `timeout` makes `open()` throw `InvalidAccessError`; a blocking request cannot be bounded this way. ## Cancellation `xhr.abort()` terminates an in-flight request. If `send()` had been called and the request had not already finished, the object fires `abort` and then `loadend`; the upload target `xhr.upload` fires the same pair, which is how per-file cancel buttons work on an upload widget. Afterwards `xhr.status` is `0`, `statusText` is empty, the response is empty, and the object has returned to an unsent state — an aborted XHR carries no partial verdict you can inspect. Cancellation matters beyond user-facing cancel buttons. The classic case is a type-ahead search: each keystroke starts a request, responses come back out of order, and the last one to arrive wins even if it was for an older query. Keeping a reference to the in-flight request and calling `abort()` before starting the next one both fixes the ordering and stops wasting bandwidth. ```js let current = null; function search(term) { if (current) current.abort(); // drop the stale one const xhr = new XMLHttpRequest(); current = xhr; xhr.open('GET', '/search?q=' + encodeURIComponent(term)); xhr.addEventListener('load', () => render(xhr.response)); xhr.addEventListener('loadend', () => { if (current === xhr) current = null; }); xhr.send(); } ``` Note the guard in `loadend`: because an aborted request also fires `loadend`, clearing the reference unconditionally would wipe out the newer request that has just been assigned. ## Which event fires when | outcome | events (in order) | | --- | --- | | complete HTTP response, any status | `load`, `loadend` | | no response at all (DNS, dropped, blocked) | `error`, `loadend` | | `xhr.timeout` elapsed | `timeout`, `loadend` | | `xhr.abort()` called in flight | `abort`, `loadend` | The table is the whole answer to the stuck-spinner symptom. There are four terminal paths and only one of them is `load`, so any teardown that lives in `load` is skipped three ways out of four. `loadend` is the only event guaranteed to run on every path, which makes it the correct home for hiding a spinner, re-enabling a button, releasing an object URL or clearing a reference. ## Diagnosing the stuck spinner When you inherit the bug, check three things in order: 1. **Where is the teardown?** If it is in the `load` handler, that alone explains it. 2. **Is `timeout` set at all?** An unbounded request against a hung server produces exactly this symptom, and no handler will ever run until the network stack gives up. 3. **Are `error` and `abort` handled?** A request blocked before it reaches the network — cross-origin, offline, a canceled navigation — fires `error` with `status === 0` and no body. If the only handler is `load`, the UI never learns. A useful hardening pass on legacy code is to centralise XHR creation in one helper that always sets a timeout, always wires all four terminal events, and always tears down in `loadend`. That converts "someone forgot" from a per-call-site risk into a single reviewed function. ## What a timeout does not tell you A `timeout` event says the browser gave up, not that the server did nothing. The request may well have reached the server and been processed. For a non-idempotent operation — a payment, an order — treating a timeout as "it did not happen" and retrying can duplicate the effect, so the safe pattern is a request identifier the server can deduplicate against.
- After xhr.abort(), what can you still read from the object?Effectively nothing useful. `xhr.status` reads `0`, `statusText` is empty, the response is empty, and the object is back in an unsent state. That is deliberate: an aborted request has no verdict, and code should treat it as a cancellation rather than trying to salvage a partial result. Any bytes already received are discarded.
- A request times out on a POST that creates an order. Is it safe to retry?Not on its own. A `timeout` event means the browser stopped waiting, not that the server did nothing — the order may already exist. Retrying blindly risks a duplicate. Make the operation idempotent by sending a client-generated request identifier the server deduplicates against, then retry safely; otherwise reconcile by querying state before resending.
- Why is xhr.timeout not a substitute for a connect timeout?Because it is a single budget for the entire exchange, started at `send()` and covering connection, request, and the whole response body. A large download on a slow link can exhaust it even though the server responded promptly, while a fast connection to a hung server consumes it entirely in waiting. XHR exposes no separate connect or per-byte idle timeout.
saying these in an interview costs you the question
- Expects the error event to fire when a request times out
- Puts spinner teardown in the load handler only
- Thinks xhr.timeout is a connect timeout
- Assumes an aborted request still exposes its status
- Retries a timed-out POST assuming nothing reached the server