skip to content

XMLHttpRequest Essentials

You will learn the XHR surface that still ships in real code and the two things it does that fetch cannot. Interviewers ask this to check historical literacy and to see whether you know why upload progress still means XHR.

on this pageshow

questions

5

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.

level: middleimportance: must knowfreq 62%

answer

  1. five states, one of them repeats
  2. state four is not a verdict
  3. the event name carries the outcome
  4. 404 is a response, not a failure
  5. one event always fires last

basics

~20 s

An 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 lines
javascript
const 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

for a junior

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.

for a middle

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.

for a senior

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.

for a principal

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

context

open as a page

Your upload widget must show a live progress bar while a file is being sent to the server. Why does that still mean XMLHttpRequest rather than fetch, and which object do you attach the progress listener to?

level: middleimportance: must knowfreq 58%

basics

~20 s

XMLHttpRequest exposes an upload object, and progress events fired on xhr.upload report bytes sent. As of 2026 fetch has no equivalent upload-progress event, which is why file-upload widgets that show a live bar are still written against XHR.

open as a page

In XMLHttpRequest, what does setting `xhr.responseType = 'json'` change about how you read the result, and why does reading `xhr.responseText` afterwards throw?

level: juniorimportance: should knowfreq 38%

basics

~20 s

Setting XMLHttpRequest's responseType to 'json' makes the browser parse the body for you and expose the parsed value on xhr.response. Reading xhr.responseText then throws an InvalidStateError, because that getter is only legal when responseType is the empty string or 'text'.

open as a page

What actually happens when a page opens a synchronous XMLHttpRequest with xhr.open('GET', url, false), and why do browsers treat that as deprecated?

level: seniorimportance: should knowfreq 45%

basics

~20 s

Passing false as the third argument to XMLHttpRequest's open() makes send() block the thread until the whole response arrives. On the main thread that freezes rendering, input and every other task for the duration, which is why browsers log a deprecation warning and restrict the mode.

open as a page

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%

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.

open as a page