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:
- Legacy code. Plenty of scripts still use
XMLHttpRequest, and you’ll need to read and maintain them. - Old environments without polyfills. If you’re targeting ancient browsers and want to keep your bundle tiny,
XMLHttpRequestships built-in. - Things
fetchstill can’t do well. Upload progress is the classic example — tracking how many bytes have gone out is trivial withXMLHttpRequestand awkward withfetch.
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.
-
Create an
XMLHttpRequestobject. The constructor takes no arguments:let xhr = new XMLHttpRequest(); -
Configure it, usually right after creating it:
xhr.open(method, URL, [async, user, password])opensets 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— passfalseto 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,
opendoes not open a connection. It only records what the request should be. Nothing touches the network until you callsend. -
Send it:
xhr.send([body])This is the step that actually opens the connection and ships the request to the server. The optional
bodycarries the request payload. AGETrequest has no body; aPOSTtypically usesbodyto send data. Examples of that come later. -
React to
xhrevents 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, soloadfires 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}`); };
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:
statusThe HTTP status code as a number:
200,404,403, and so on. It’s0when the failure wasn’t HTTP at all (for example, a network error or an aborted request).
statusTextThe HTTP status message as a string: usually
OKfor200,Not Foundfor404,Forbiddenfor403, and so on.
response(old scripts may useresponseText)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"— anArrayBufferfor binary data (see ArrayBuffer, binary arrays)."blob"— aBlobfor 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: 0 → 1 → 2 → 3 → … → 3 → 4. State 3 repeats — it fires once for each data packet received over the network, which is why the arrow loops back to it.
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-CookieandSet-Cookie2excluded for security):xhr.getResponseHeader('Content-Type')
getAllResponseHeaders()Returns every response header (again minus
Set-CookieandSet-Cookie2) as one string:Cache-Control: max-age=31536000 Content-Length: 4260 Content-Type: image/png Date: Sat, 08 Sep 2012 16:53:16 GMTThe 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:
xhr.open('POST', ...)to use thePOSTmethod, andxhr.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 iftimeoutis set).loadend— the upload finished, whether it succeeded or failed.
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 inresponse.abort— the request was canceled byxhr.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 afterload,error,timeout, orabort.
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.
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.