XMLHttpRequest

XMLHttpRequest is a built-in browser object for making HTTP requests from JavaScript. It predates fetch by well over a decade, and for a long time it was the only way to talk to a server without reloading the page.

The name is a historical accident. The “XML” part suggests it only handles XML, but that was never true — it moves any kind of data. You can download text, JSON, images, or binary blobs, upload files, and watch progress tick along the way.

These days fetch covers most of what you’d reach for XMLHttpRequest to do, with a cleaner promise-based shape. So why learn the older API at all? Three practical reasons:

  1. Legacy code. Plenty of scripts still use XMLHttpRequest, and you’ll need to read and maintain them.
  2. Old environments without polyfills. If you’re targeting ancient browsers and want to keep your bundle tiny, XMLHttpRequest ships built-in.
  3. Things fetch still can’t do well. Upload progress is the classic example — tracking how many bytes have gone out is trivial with XMLHttpRequest and awkward with fetch.

If none of that describes your situation, you can skip ahead to Fetch. Otherwise, read on.

The basics

XMLHttpRequest has two modes: asynchronous and synchronous. Asynchronous is what you want almost every time, so we’ll start there and come back to the synchronous mode later.

A request goes through four moves: create, configure, send, and react. The first three are quick lines; the fourth is where the response actually lands.

  1. Create an XMLHttpRequest object. The constructor takes no arguments:

    let xhr = new XMLHttpRequest();
  2. Configure it, usually right after creating it:

    xhr.open(method, URL, [async, user, password])

    open sets up the main parameters of the request:

    • method — the HTTP method, commonly "GET" or "POST".
    • URL — the address to request, a string or a URL object.
    • async — pass false to make the request synchronous (covered later). Anything else, or leaving it out, keeps it asynchronous.
    • user, password — credentials for basic HTTP auth, if the server needs them.

    Despite the name, open does not open a connection. It only records what the request should be. Nothing touches the network until you call send.

  3. Send it:

    xhr.send([body])

    This is the step that actually opens the connection and ships the request to the server. The optional body carries the request payload. A GET request has no body; a POST typically uses body to send data. Examples of that come later.

  4. React to xhr events as the response arrives.

    Three events cover most needs:

    • load — the request finished and the response is fully downloaded. This fires even when the HTTP status is an error like 404 or 500, because from the network’s point of view the exchange completed.
    • error — the request couldn’t be made at all: network down, DNS failure, invalid URL. Note that an HTTP 404 is not an error here — the server answered, so load fires instead.
    • progress — fires repeatedly while the response body downloads, reporting how much has arrived.
    xhr.onload = function() {
      alert(`Loaded: ${xhr.status} ${xhr.response}`);
    };
    
    xhr.onerror = function() { // only fires if the request couldn't be made at all
      alert(`Network Error`);
    };
    
    xhr.onprogress = function(event) { // fires periodically
      // event.loaded — how many bytes downloaded so far
      // event.lengthComputable — true if the server sent a Content-Length header
      // event.total — total number of bytes (only meaningful if lengthComputable)
      alert(`Received ${event.loaded} of ${event.total}`);
    };
new XMLHttpRequest()
xhr.open(…)
xhr.send()
↓ network activity begins here
progress
(repeats)
load
or
error
The lifecycle of an async XMLHttpRequest: three setup calls, then events fire as the response streams in.

Here’s a full example. It downloads whatever the server has at /api/report/download and reports progress as the bytes come in:

// 1. Create a new XMLHttpRequest object
let xhr = new XMLHttpRequest();

// 2. Configure it: GET request for the URL /api/report/download
xhr.open('GET', '/api/report/download');

// 3. Send the request over the network
xhr.send();

// 4. This runs once the response has been received
xhr.onload = function() {
  if (xhr.status != 200) { // check the HTTP status of the response
    alert(`Error ${xhr.status}: ${xhr.statusText}`); // e.g. 404: Not Found
  } else { // show the result
    alert(`Done, got ${xhr.response.length} bytes`); // response holds the server's answer
  }
};

xhr.onprogress = function(event) {
  if (event.lengthComputable) {
    alert(`Received ${event.loaded} of ${event.total} bytes`);
  } else {
    alert(`Received ${event.loaded} bytes`); // no Content-Length header sent
  }
};

xhr.onerror = function() {
  alert("Request failed");
};

Once the server responds, the result lives in a few xhr properties:

status

The HTTP status code as a number: 200, 404, 403, and so on. It’s 0 when the failure wasn’t HTTP at all (for example, a network error or an aborted request).

statusText

The HTTP status message as a string: usually OK for 200, Not Found for 404, Forbidden for 403, and so on.

response (old scripts may use responseText)

The server’s response body.

You can also set a timeout:

xhr.timeout = 10000; // timeout in ms — here, 10 seconds

If the request doesn’t complete within that window, it’s canceled and the timeout event fires.

Response type

By default the response comes back as a string. The xhr.responseType property lets you ask for a different shape, and the browser converts it for you:

  • "" (default) — a string.
  • "text" — a string.
  • "arraybuffer" — an ArrayBuffer for binary data (see ArrayBuffer, binary arrays).
  • "blob" — a Blob for binary data (see Blob).
  • "document" — a parsed XML document (queryable with XPath and other XML methods) or an HTML document, chosen by the MIME type of the response.
  • "json" — parsed JSON, ready to use as an object.

Asking for "json" saves you a JSON.parse call:

let xhr = new XMLHttpRequest();

xhr.open('GET', '/api/status.json');

xhr.responseType = 'json';

xhr.send();

// the raw response is {"message": "Hello, world!"}
xhr.onload = function() {
  let responseObj = xhr.response;
  alert(responseObj.message); // Hello, world!
};

Ready states

An XMLHttpRequest moves through a sequence of internal states as it works. The current one is in xhr.readyState.

The states, straight from the specification:

UNSENT = 0;           // initial state, before open()
OPENED = 1;           // open() has been called
HEADERS_RECEIVED = 2; // response headers have arrived
LOADING = 3;          // response body is downloading (a data packet arrived)
DONE = 4;             // request complete

The object walks them in order: 0123 → … → 34. State 3 repeats — it fires once for each data packet received over the network, which is why the arrow loops back to it.

0
UNSENT
1
OPENED
2
HEADERS
3
LOADING ↻
4
DONE
readyState progression. State 3 (LOADING) repeats once per data packet before the request reaches 4 (DONE).

You can watch the transitions with the readystatechange event:

xhr.onreadystatechange = function() {
  if (xhr.readyState == 3) {
    // loading — a chunk arrived
  }
  if (xhr.readyState == 4) {
    // request finished
  }
};

readystatechange shows up in older code because there was a time before load, error, and progress existed — this was the only way to know what stage a request had reached. In new code the dedicated events do the job more clearly, so you rarely need it.

Aborting a request

You can stop a request at any point with abort:

xhr.abort(); // terminate the request

That fires the abort event and sets xhr.status to 0. This is handy when a result has gone stale — say, a newer search query has superseded an in-flight one.

Synchronous requests

Pass false as the third argument to open and the request runs synchronously.

That means JavaScript stops dead at send() and doesn’t continue until the response arrives — much like alert or prompt freeze the page while they’re open.

Here’s the earlier example rewritten with async set to false:

let xhr = new XMLHttpRequest();

xhr.open('GET', '/api/greeting.txt', false);

try {
  xhr.send();
  if (xhr.status != 200) {
    alert(`Error ${xhr.status}: ${xhr.statusText}`);
  } else {
    alert(xhr.response);
  }
} catch(err) { // takes the place of onerror
  alert("Request failed");
}

Notice the shape changes: there are no onload/onerror handlers. Since execution blocks, you read xhr.status and xhr.response on the line right after send(), and you catch failures with a plain try...catch.

This might look simpler, but synchronous requests are rare for good reason. They freeze all in-page JavaScript until the load finishes. In some browsers the page won’t even scroll while it waits, and if the request drags on, the browser may offer to kill the “hanging” page.

Synchronous mode also loses features: no timeout, no progress reporting, and some cross-origin capabilities are off the table. Because of all this, synchronous requests are used almost never. We won’t return to them.

HTTP headers

XMLHttpRequest lets you both send custom request headers and read the ones on the response. Three methods handle this.

setRequestHeader(name, value)

Sets a request header:

xhr.setRequestHeader('Content-Type', 'application/json');
getResponseHeader(name)

Reads a single response header by name (with Set-Cookie and Set-Cookie2 excluded for security):

xhr.getResponseHeader('Content-Type')
getAllResponseHeaders()

Returns every response header (again minus Set-Cookie and Set-Cookie2) as one string:

Cache-Control: max-age=31536000
Content-Length: 4260
Content-Type: image/png
Date: Sat, 08 Sep 2012 16:53:16 GMT

The format is fixed by the specification, which makes it safe to parse. Headers are separated by "\r\n" regardless of the operating system, and each name is separated from its value by a colon and a space, ": ".

So turning that string into an object takes a little JavaScript. This version assumes that when two headers share a name, the later one wins:

let headers = xhr
  .getAllResponseHeaders()
  .split('\r\n')
  .reduce((result, current) => {
    let [name, value] = current.split(': ');
    result[name] = value;
    return result;
  }, {});

// headers['Content-Type'] = 'image/png'

POST and FormData

To send a POST, the easiest payload builder is the built-in FormData object.

let formData = new FormData([form]); // create it, optionally seeded from a <form>
formData.append(name, value);        // add a field

You create it, optionally pre-fill it from a form element, append any extra fields, then:

  1. xhr.open('POST', ...) to use the POST method, and
  2. xhr.send(formData) to submit it.

For example:

<form name="person">
  <input name="name" value="Maya">
  <input name="surname" value="Vance">
</form>

<script>
  // seed FormData from the form's fields
  let formData = new FormData(document.forms.person);

  // add one more field on top
  formData.append("middle", "Rae");

  // send it out
  let xhr = new XMLHttpRequest();
  xhr.open("POST", "/api/members/create");
  xhr.send(formData);

  xhr.onload = () => alert(xhr.response);
</script>

A FormData body goes out with multipart/form-data encoding, which is what forms use natively.

Prefer JSON? Stringify your object and send the string. Set the Content-Type header so the server knows how to decode it — many backend frameworks parse JSON automatically when they see application/json:

let xhr = new XMLHttpRequest();

let json = JSON.stringify({
  name: "Maya",
  surname: "Vance"
});

xhr.open("POST", '/submit')
xhr.setRequestHeader('Content-type', 'application/json; charset=utf-8');

xhr.send(json);

The send(body) method is easygoing about what it accepts. Beyond strings and FormData, it takes Blob and BufferSource objects too, so binary payloads work without extra ceremony.

Upload progress

Here’s a subtlety that trips people up: the progress event fires only during the download stage.

Think about what a POST actually does. First it uploads the request body to the server, then it downloads the response. The xhr.onprogress handler watches the second half — the download. It tells you nothing about the upload.

That matters when you’re sending something large, like a file. You want to know how the upload is going, and xhr.onprogress won’t help.

The fix is a separate object: xhr.upload. It has no methods of its own, but it emits its own set of events, all fired purely for the upload phase:

  • loadstart — the upload began.
  • progress — fires periodically as bytes go out.
  • abort — the upload was aborted.
  • error — a non-HTTP error occurred.
  • load — the upload finished successfully.
  • timeout — the upload timed out (only if timeout is set).
  • loadend — the upload finished, whether it succeeded or failed.
Phase 1 — upload body
xhr.upload.onprogress
tracks bytes going out
Phase 2 — download response
xhr.onprogress
tracks bytes coming back
time →
A POST has two phases. xhr.upload events cover sending the body; xhr events cover receiving the response.

Wiring up upload handlers looks just like the download ones, but on xhr.upload:

xhr.upload.onprogress = function(event) {
  alert(`Uploaded ${event.loaded} of ${event.total} bytes`);
};

xhr.upload.onload = function() {
  alert(`Upload finished successfully.`);
};

xhr.upload.onerror = function() {
  alert(`Error during the upload: ${xhr.status}`);
};

A real example — a file input that reports progress as it uploads:

<input type="file" onchange="upload(this.files[0])">

<script>
function upload(file) {
  let xhr = new XMLHttpRequest();

  // track upload progress
  xhr.upload.onprogress = function(event) {
    console.log(`Uploaded ${event.loaded} of ${event.total}`);
  };

  // track completion — success or failure alike
  xhr.onloadend = function() {
    if (xhr.status == 200) {
      console.log("success");
    } else {
      console.log("error " + this.status);
    }
  };

  xhr.open("POST", "/api/uploads");
  xhr.send(file);
}
</script>

Cross-origin requests

XMLHttpRequest can request from other origins, following the same CORS rules as fetch.

Like fetch, it won’t send cookies or HTTP authorization to a different origin by default. To include them, set xhr.withCredentials to true:

let xhr = new XMLHttpRequest();
xhr.withCredentials = true;

xhr.open('POST', 'http://anywhere.com/request');
// ...

For the full picture on cross-origin headers, see Fetch: Cross-Origin Requests.

Summary

A typical GET with XMLHttpRequest reads like this:

let xhr = new XMLHttpRequest();

xhr.open('GET', '/my/url');

xhr.send();

xhr.onload = function() {
  if (xhr.status != 200) { // HTTP error?
    // handle it
    alert( 'Error: ' + xhr.status);
    return;
  }

  // read the result from xhr.response
};

xhr.onprogress = function(event) {
  // report download progress
  alert(`Loaded ${event.loaded} of ${event.total}`);
};

xhr.onerror = function() {
  // handle a non-HTTP error (e.g. network down)
};

There are more events than we’ve used. The modern specification lists them in lifecycle order:

  • loadstart — the request has started.
  • progress — a chunk of the response arrived; everything received so far is in response.
  • abort — the request was canceled by xhr.abort().
  • error — a connection error occurred, such as a bad domain name. This does not fire for HTTP errors like 404.
  • load — the request finished successfully.
  • timeout — the request was canceled because it exceeded a set timeout.
  • loadend — fires after load, error, timeout, or abort.

The error, abort, timeout, and load events are mutually exclusive — exactly one of them happens for any given request. Then loadend fires afterward no matter which one it was.

load
error
abort
timeout
exactly one ↓
loadend
One terminal event fires per request (error, abort, timeout, or load), then loadend always follows.

In practice you’ll most often listen for load (success) and error (failure), or use a single loadend handler and inspect the xhr properties to see what happened.

We also met readystatechange, which predates the settled specification. There’s no need to reach for it in new code — the newer events replace it — but you’ll keep running into it in older scripts.

And when you specifically need to follow an upload, listen for these same events on the xhr.upload object instead of xhr itself.