skip to content

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?

level: seniorimportance: should knowfreq 40%

answer

  1. default is zero, meaning no limit
  2. expiry is its own event
  3. cancel returns you to nothing
  4. four ways to finish, one event covers all
  5. teardown belongs in the last one

basics

~20 s

Set 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 lines
javascript
function 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

for a junior

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.

for a middle

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.

for a senior

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.

for a principal

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

context