Walk through the readyState values an XMLHttpRequest passes through, and explain why modern XHR code listens for the load and error events instead of onreadystatechange.
answer
- five states, one of them repeats
- state four is not a verdict
- the event name carries the outcome
- 404 is a response, not a failure
- one event always fires last
basics
~20 sAn XMLHttpRequest moves through readyState 0 UNSENT, 1 OPENED, 2 HEADERS_RECEIVED, 3 LOADING and 4 DONE, firing readystatechange on each step. Modern code prefers the load, error, timeout, abort and loadend events because each names one outcome instead of requiring a state check.
solid answer
~50 s`readyState` is a five-value state machine: `0` UNSENT before `open()`, `1` OPENED after it, `2` HEADERS_RECEIVED once the status line and headers have arrived, `3` LOADING while the body streams in, and `4` DONE when the transfer has finished — successfully or not. `readystatechange` fires on every transition, and repeatedly at `3` as chunks arrive. The problem is that it tells you nothing about the outcome: every handler begins with `if (xhr.readyState === 4)` and then has to inspect `xhr.status` to work out what happened. The progress-event set — `loadstart`, `progress`, `load`, `error`, `timeout`, `abort`, `loadend` — encodes the outcome in the event name. The rule people get wrong is that `load` means "a response arrived", not "it succeeded": a 404 or a 500 fires `load`. `error` means the request never produced an HTTP response at all — DNS failure, dropped connection, a cross-origin block — and there `xhr.status` reads `0`. `loadend` always fires last, whatever the outcome, which makes it the right place to hide a spinner.
code
javascript · 13 linesconst xhr = new XMLHttpRequest();
xhr.open('GET', '/api/report');
xhr.responseType = 'json';
xhr.addEventListener('load', () => {
// fires for 200 AND for 404/500 - status is the verdict
if (xhr.status >= 200 && xhr.status < 300) render(xhr.response);
else showServerError(xhr.status);
});
xhr.addEventListener('error', () => showOffline()); // status is 0 here
xhr.addEventListener('loadend', () => hideSpinner()); // every outcome
xhr.send();go deeper
Be able to name the five readyState values in order and say that 4 means finished, not successful. Knowing that you still have to check xhr.status is the point that gets tested.
Explain what each state unlocks — setRequestHeader at OPENED, headers at HEADERS_RECEIVED, partial text at LOADING — and contrast readystatechange with the load/error/loadend events, including that readystatechange repeats during state 3.
Demonstrate the diagnostic split: load with a status means the server answered, error with status 0 means no response existed, and both end in loadend. Explain why cross-origin failures are deliberately opaque to the script.
Own the convention: decide how your codebase distinguishes transport failure from application failure across every networking layer, so retry policy, alerting and error budgets are driven by the same distinction rather than each call site inventing its own.
## The state machine Every `XMLHttpRequest` walks one path, and `xhr.readyState` reports where it is. The numbers have named constants on the constructor and the instance (`XMLHttpRequest.DONE === 4`). | value | constant | meaning | | --- | --- | --- | | 0 | `UNSENT` | constructed; `open()` has not been called | | 1 | `OPENED` | `open()` done; headers may be set; `send()` not yet called | | 2 | `HEADERS_RECEIVED` | status line and response headers have arrived | | 3 | `LOADING` | the body is arriving | | 4 | `DONE` | the transfer finished, succeeded or failed | `readystatechange` fires on each transition. One detail that surprises people: it also fires **repeatedly while the state stays at 3**, once per chunk of body the browser processes. If your handler does work without guarding on the state, it runs many times per request. ## What each state actually unlocks At `OPENED` (1) you may call `setRequestHeader()`; before `open()` it throws, and after `send()` it throws. At `HEADERS_RECEIVED` (2) the metadata is readable: `xhr.status`, `xhr.statusText`, `xhr.getResponseHeader(name)` and `xhr.getAllResponseHeaders()`. This is where you could decide to `abort()` a response you do not want the body of. For a cross-origin response the browser only exposes the CORS-safelisted headers plus whatever the server listed in `Access-Control-Expose-Headers`, so `getAllResponseHeaders()` returning a short string cross-origin is expected, not a bug. At `LOADING` (3), and only when `responseType` is `''` or `'text'`, `xhr.responseText` holds the portion decoded so far. At `DONE` (4) the body is complete — or the request failed. `DONE` on its own carries no verdict. ## Why the outcome events are better The classic handler looks like this: ```js xhr.onreadystatechange = function () { if (xhr.readyState !== 4) return; if (xhr.status >= 200 && xhr.status < 300) ok(xhr.response); else fail(xhr.status); }; ``` Everything before the interesting line is ceremony, and the `else` branch quietly conflates two very different situations: a server that answered with 500, and a request that never reached a server at all. `XMLHttpRequest` inherits the progress-event set from `XMLHttpRequestEventTarget`, and each event names one outcome: - `loadstart` — `send()` has begun the transfer. - `progress` — bytes of the **response** are arriving (a `ProgressEvent` with `lengthComputable`, `loaded`, `total`). - `load` — a complete HTTP response arrived. **Any** status: 200, 304, 404, 500. - `error` — the request failed at the network level, so no HTTP response exists. - `timeout` — `xhr.timeout` elapsed first. - `abort` — `xhr.abort()` was called. - `loadend` — fires last on **every** one of the above. ```js xhr.addEventListener('load', () => { if (xhr.status >= 200 && xhr.status < 300) ok(xhr.response); else serverSaidNo(xhr.status); // a real HTTP answer }); xhr.addEventListener('error', () => networkFailed()); // no answer at all xhr.addEventListener('loadend', () => hideSpinner()); // always runs ``` ## load does not mean success This is the single most-asked point in the area, and it mirrors the behaviour people already know from other browser networking APIs: the transport succeeded, so the API reports success; the HTTP status is application-level information you still have to read. A 404 is a response the server deliberately sent. Treating `load` as "it worked" produces the bug where an error page's HTML is fed into code expecting data. Conversely, when `error` fires, `xhr.status` is `0`, `xhr.statusText` is empty, and the response body is empty. That triple is also what you see after `abort()` and after a cross-origin block — the browser deliberately refuses to tell a script *why* a cross-origin request failed, because the failure reason would itself leak information. ## Practical guidance In code you own, wire `load`, `error`, `timeout` and `abort`, and put teardown in `loadend`. Keep `readystatechange` for the two things only it can do: acting at `HEADERS_RECEIVED` before the body arrives, and reading partial text at `LOADING`. When you are reading someone else's `onreadystatechange` handler, check two things — that it guards on `readyState === 4`, and that it checks `xhr.status` rather than assuming state 4 means success.
- At which readyState can you first read the response headers, and what limits what you see cross-origin?At `HEADERS_RECEIVED` (2): `status`, `statusText`, `getResponseHeader()` and `getAllResponseHeaders()` are all populated before any body arrives. For a cross-origin response the browser exposes only the CORS-safelisted response headers plus any the server named in `Access-Control-Expose-Headers`, so a short header string cross-origin is the expected behaviour rather than a bug.
- How do you distinguish an HTTP 500 from a dropped connection in XHR?By which event fires. A 500 is a complete response, so `load` fires and `xhr.status` reads `500` with a readable body. A dropped or blocked connection produces no HTTP response, so `error` fires and `xhr.status` reads `0` with an empty body. Both then fire `loadend`. Code that only inspects `status` in one handler cannot tell them apart cleanly.
- Why can a readystatechange handler run many times for a single request?Because `readystatechange` fires on each state transition and then again for every chunk of body the browser processes while the state remains `LOADING` (3). A handler that does work without first checking `readyState` therefore executes repeatedly per request — which is why the conventional first line is an early return unless the state is `4`.
saying these in an interview costs you the question
- Thinks the error event fires for HTTP 404 or 500
- Checks readyState 4 but never checks xhr.status
- Says readystatechange fires exactly five times
- Believes readyState 3 means the request succeeded
- Assumes loadend only fires after a successful load