JavaScriptAJAX & XMLHttpRequest

AJAX and XMLHttpRequest

Before fetch there was AJAX — Asynchronous JavaScript And XML — and the API at the heart of it was XMLHttpRequest (XHR). It is the original way browsers made HTTP calls without reloading the page. Today fetch is the default for new code, but XHR has not gone away: it lives on inside libraries, in legacy applications, and as the only standard browser API for upload progress events.

What XHR is

XMLHttpRequest is a constructor that creates a request object. You configure it (method, URL, headers), attach event listeners for state changes, then call .send(). The transfer happens asynchronously and the events fire as the response arrives.

  • Introduced by Microsoft in 1999 (in Outlook Web Access) and standardised across browsers a few years later.

  • The "XML" in the name is historical — the response can be plain text, JSON, a Blob, an ArrayBuffer or a Document.

  • It powered the AJAX revolution that made Gmail, Google Maps and modern single-page apps possible.

A basic GET with XHR

JS
const xhr = new XMLHttpRequest();
xhr.open("GET", "/api/user/42");
xhr.responseType = "json";        // automatic parsing

xhr.addEventListener("load", () => {
  if (xhr.status >= 200 && xhr.status < 300) {
    console.log("user:", xhr.response);
  } else {
    console.error("server error", xhr.status);
  }
});

xhr.addEventListener("error", () => console.error("network error"));

xhr.send();

Compare that to the same operation with fetch:

The fetch equivalent

JS
const response = await fetch("/api/user/42");
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const user = await response.json();
POSTing JSON

JS
function postJson(url, payload) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open("POST", url);
    xhr.setRequestHeader("Content-Type", "application/json");
    xhr.responseType = "json";

    xhr.addEventListener("load", () => {
      if (xhr.status >= 200 && xhr.status < 300) resolve(xhr.response);
      else reject(new Error(`HTTP ${xhr.status}`));
    });
    xhr.addEventListener("error", () => reject(new Error("network error")));
    xhr.addEventListener("abort", () => reject(new Error("aborted")));

    xhr.send(JSON.stringify(payload));
  });
}

Most XHR usage in modern code looks like this — wrap it once in a promise and never touch the constructor again.

Events and readyState

Each XHR moves through five readyState values; the readystatechange event fires at every transition. In day-to-day code you can ignore readyState and listen to the named events:

  • loadstart — the request has been sent.

  • progress — fires repeatedly while the response is downloading. Useful for progress bars.

  • load — the response is complete (success or HTTP error).

  • error — a network-level failure (DNS, CORS, offline).

  • timeoutxhr.timeout was exceeded.

  • abortxhr.abort() was called.

  • loadend — fires after any of the above. The "finally" event.

HTTP errors are not network errors
Just like `fetch`, an HTTP 500 still fires `load` — you have to check `xhr.status` yourself. The `error` event is reserved for "could not reach the server".
Upload progress — XHR's last superpower

fetch does not expose upload progress at the spec level (request streams are coming, but uneven). XHR has had it since the beginning via xhr.upload:

JS
const xhr = new XMLHttpRequest();
xhr.open("POST", "/upload");

xhr.upload.addEventListener("progress", (event) => {
  if (event.lengthComputable) {
    const pct = Math.round((event.loaded / event.total) * 100);
    progressBar.value = pct;
  }
});

xhr.addEventListener("load", () => console.log("done"));
xhr.send(formData);

This is the single most common reason you still see XHR in 2026 — file upload widgets in apps that want a progress bar.

Cancellation, timeout, credentials
  • xhr.abort() cancels in-flight requests — the fetch equivalent of controller.abort().

  • xhr.timeout = 5000 rejects with a timeout event after 5 seconds — fetch needs AbortSignal.timeout.

  • xhr.withCredentials = true sends cookies cross-origin — like fetch({ credentials: "include" }).

  • xhr.setRequestHeader(name, value) adds a header; some headers (e.g. Host, Cookie) are forbidden by the spec.

When you still need to know XHR
  • You are debugging a legacy codebase or an older library (jQuery $.ajax, Axios in some configurations) that wraps XHR.

  • You need granular upload progress — XHR is still the most portable answer.

  • You are dealing with a quirky environment (some service workers, older browsers, embedded webviews) where fetch is missing or behaves oddly.

  • You are reading older blog posts or interview material that assumes XHR knowledge.

Reach for fetch first
Write new code with `fetch` and `async/await`. Drop down to `XMLHttpRequest` only when you genuinely need upload progress or you are maintaining code that already uses it. Most apps never have to touch XHR directly.