XMLHttpRequest
Learn the older browser request API: the XMLHttpRequest lifecycle, readyState events, progress events, response types, and when XHR is still useful in legacy JavaScript.
- 01Follow a request step by stepFollow
open(),send(), thereadyStatenumbers, and the order of the events. - 02Tell an error answer from no answerTell a 404 answer apart from a network failure, an abort, and a timeout.
- 03Know when XHR is still the right toolSpot the places where XHR still appears: upload progress bars and older code.
Why learn XMLHttpRequest?
An HTTP request is a message your page sends to a server. It asks for data, or it sends data. XMLHttpRequest is an object the browser gives you for sending HTTP requests from JavaScript. People shorten its name to XHR.
XHR is the older way to send requests. Newer code usually uses fetch(). You still need to know XHR, because you will meet it in these places:
- Older apps that were written before
fetch()existed. - Older libraries, such as old jQuery code, that use XHR inside.
- Upload progress bars, because XHR can report how much of a file has been sent.
The name sounds big, but the steps are small:
- Create a request object.
- Open it with a method, such as
GET, and an address (a URL). - Add listeners for its events. A listener is a function that runs when its event happens.
- Send it.
XHR doesn't give you a promise. It tells you what happened in two ways: through events, and through a number called readyState.
An old rotary phone still makes calls. You turn the dial, hear each click, and wait for the call to go through. You can see every step. A smartphone does the same job with less work.
- In real life: A rotary phone can still make calls
- In JavaScript: XHR still works in every browser
- In real life: You turn the dial, hear each click, and wait for each step
- In JavaScript: XHR shows each step:
readyState,readystatechange, and the progress, timeout, and abort events - In real life: You find it in older houses
- In JavaScript: You find XHR in older code and libraries
- In real life: A smartphone is easier for most new calls
- In JavaScript:
fetch()is usually easier for new request code
In this lesson you will send real requests, but only to this website. The demos ask for one of these:
- This page.
- A page on this site that doesn't exist.
- A
blob:URL. A blob URL points at data that the page made itself, so no other server is involved.
One more word before we start. A header is a short line of extra information sent with a request or an answer, such as the type of data inside it.
open(method, url, async) gets the request ready. setRequestHeader() comes after open() and before send(). send(body) starts the request. Then events tell you what happened.
send() runs.The XHR lifecycle
INTERACTIVEA lifecycle is the list of steps something goes through, from start to end. A request has two sides:
- The code you write.
- The events the browser fires back. An event is a signal, such as "the request started" or "the answer arrived".
Here is the code side, in the order it runs:
const xhr = new XMLHttpRequest();
xhr.open("GET", url);
xhr.timeout = timeoutMs;
xhr.addEventListener("readystatechange", record);
xhr.addEventListener("loadstart", record);
xhr.addEventListener("progress", record);
xhr.addEventListener("load", record);
xhr.addEventListener("error", record);
xhr.addEventListener("abort", record);
xhr.addEventListener("timeout", record);
xhr.addEventListener("loadend", record);
xhr.send();- Line 1 creates the request object.
- Line 2 calls
open()with the methodGETand the address inurl. - Line 3 sets a time limit, in milliseconds, with
timeout. - Lines 4 to 11 add one listener for each event. Every listener here is
record, which prints the event's name. - Line 12 calls
send(), which starts the request.
The next example is a tiny fake request object. It shows the order of the calls without using the network. It uses status, the HTTP status code. That is a number the server sends back, such as 200 for "OK" or 404 for "Not Found".
const events = [];
const xhr = {
status: 0,
open(method, url) { events.push("open " + method + " " + url); },
send() { this.status = 200; events.push("load " + this.status); },
};
xhr.open("GET", "/profile");
xhr.send();
console.log(events.join(" -> "));- Line 4:
open()saves the textopen GET /profile. - Line 5:
send()setsstatusto 200 and savesload 200. - Lines 7 and 8 call
open(), thensend(). - Line 9 prints
open GET /profile -> load 200.
A 404 and a broken connection are not the same thing:
- A 404 means the server answered. Its answer was "not found".
- A broken connection means no answer came back at all.
XHR fires a different event for each. load means an answer arrived, whatever its status. error means no answer reached your code. This small model prints the two cases without contacting a server:
function describeOutcome(status, eventName) {
if (eventName === "load") return "load with status " + status;
return eventName + " with status " + status;
}
console.log(describeOutcome(404, "load"));
console.log(describeOutcome(0, "error"));Line 6 prints load with status 404. Line 7 prints error with status 0.
load fires. With no answer at all, error fires and status stays 0.One more surprise: open() does not send anything. It only saves the method and the URL, and changes readyState to 1, which is named OPENED. The request leaves only when send() runs.
const xhr = new XMLHttpRequest();xhr.open("GET", targetUrl);xhr.timeout = target === "timeout" ? 1 : 0;xhr.responseType = "text";for (const name of ["loadstart", "progress", "load", "error", "abort", "timeout", "loadend"]) { xhr.addEventListener(name, logEvent);}xhr.addEventListener("readystatechange", logReadyState);xhr.send();if (target === "abort") setTimeout(() => xhr.abort(), 0);No request has run yet.
Pick a target, then press Send. The request stays on this website.
Try "Missing page". You get a real 404 status, but the outcome event is still load. That is because XHR answers two different questions:
- Did an answer arrive?
loadsays yes. - Did the server give you what you asked for? Only
statustells you that.
Some requests get no answer that your code can read. In these cases status usually stays 0:
- A revoked blob URL: the page has already thrown that data away.
- A timeout: the time limit ran out.
- An abort: your code stopped the request.
- A CORS failure: another site didn't allow your page to read its answer.
readyState and events
STEP THROUGHreadyState is a number on the request object. It tells you which step the request has reached. It goes from 0 to 4. Each number also has a name, kept on XMLHttpRequest. For example, XMLHttpRequest.DONE is 4. People write the names in logs, because the numbers alone are hard to remember.
Ordering a pizza has steps you can name. You look at the menu. You call. The shop answers. The pizza is on its way. Then it arrives. An XHR request goes through five named steps in the same way.
- In real life: 0: the menu is in your hand
- In JavaScript:
UNSENT: the object exists, butopen()hasn't run - In real life: 1: you called the shop
- In JavaScript:
OPENED: the method and URL are set - In real life: 2: the shop answered the phone
- In JavaScript:
HEADERS_RECEIVED: the status and headers have arrived - In real life: 3: the pizza is on its way
- In JavaScript:
LOADING: the body of the answer is arriving - In real life: 4: the pizza arrives, even if it's the wrong one
- In JavaScript:
DONE: the request ended: it worked, failed, timed out, or was stopped
The answer from a server has two parts:
- The headers: short lines of information, including the status.
- The body: the main content, such as a page or some data.
In the table, each row is one step. Read it from left to right: the number, its name, what it means, and what you can read at that step.
| Number | Name | What it means | What can you read now? |
|---|---|---|---|
| 0 | UNSENT | The object exists, but it isn't opened yet. | Nothing yet. You can only set the request up. |
| 1 | OPENED | open() has run. No answer has arrived yet. | Nothing yet. You can only set the request up. |
| 2 | HEADERS_RECEIVED | The status and the headers have arrived. | status and the response headers. |
| 3 | LOADING | The body is arriving. | Progress, and sometimes the text received so far. |
| 4 | DONE | The request has ended. | status, the headers, and response. |
const names = ["UNSENT", "OPENED", "HEADERS_RECEIVED", "LOADING", "DONE"];
for (const state of [0, 1, 2, 3, 4]) {
console.log(state + " " + names[state]);
}The loop goes through the numbers 0 to 4. For each one, it prints the number and its name, from 0 UNSENT to 4 DONE.
readyState counts up from 0 to 4. At 4, DONE, the request has ended, whether it worked or failed.The events don't match the steps one to one. Here is the order they fire in:
readystatechangefires each timereadyStatechanges.loadstartfires once, when the request starts.progressfires while bytes of the answer arrive. A byte is one small piece of data.- Exactly one outcome event fires:
load,error,abort, ortimeout. loadendfires last, every time. It is the place to clean up, such as hiding a loading spinner.
The readystatechange events happen in between the others, as the number goes up. Step through the cases below to see the full order.
Step through the "Success (200)" case. Every case has the same shape: a start, then state changes (and progress, if bytes arrive), then one outcome event, then loadend.
script
xhr.open("GET", url);xhr.timeout = timeoutMs;xhr.addEventListener("readystatechange", record);xhr.addEventListener("loadstart", record);xhr.addEventListener("progress", record);xhr.addEventListener("load", record);xhr.addEventListener("error", record);xhr.addEventListener("abort", record);xhr.addEventListener("timeout", record);xhr.addEventListener("loadend", record);xhr.send();Reading the answer: status and responseType
INTERACTIVEWhen the answer arrives, XHR gives you these properties and methods to read it:
statusis the HTTP status number, such as 200 or 404.statusTextis the short text for that status, such as "OK", when the browser has it.responseURLis the final address of the answer. It can be different from the address you asked for, if the server sent you on to another address. That is called a redirect.getAllResponseHeaders()returns the headers your code may see, as one string.
To read the body, use xhr.response. The kind of value it holds depends on responseType. You set responseType before you send:
""(empty, the default) or"text": a string."json": a JavaScript value made from JSON text."blob": aBlob, which is raw data like a file."arraybuffer": anArrayBuffer, which is raw bytes."document": an HTML or XML page, already turned into aDocumentobject.
There is also an older property, responseText. It only works when responseType is "" or "text". For every other type, read response.
const fakeResponse = { name: "Asha", items: 2 };
const responseType = "json";
console.log(responseType + ": " + fakeResponse.name);
console.log("items: " + fakeResponse.items);This example doesn't send a request. Line 1 builds the object that a JSON answer would turn into. Line 3 prints json: Asha, and line 4 prints items: 2. With a real request, you would read the same fields from xhr.response.
With a real request, responseType = "json" asks the browser to turn the JSON text into a value before onload runs. If the text isn't valid JSON, response is null. So check for null before you read a property.
const xhr = new XMLHttpRequest();xhr.open("GET", location.href);xhr.responseType = selectedType; // "", "text", "json", "blob", "arraybuffer", or "document"xhr.onload = () => { console.log(xhr.status); console.log(xhr.responseType || "text default"); console.log(typeof xhr.response);};xhr.send();Pick a type, then press "Request this page" to see what xhr.response holds.
Pick a type, then press "Request this page" to see what xhr.response holds.
json, response is null, because this page is HTML, not JSON. With document, the browser turns the HTML into a Document object.In the table, each row is one property or method. The middle column says what it is for. The last column says what to look out for.
| Property or method | What is it for? | What should you look out for? |
|---|---|---|
responseType | It tells the browser what kind of value response should hold. | Set it before the answer finishes arriving. |
response | It holds the body: a string, an object, a Blob, an ArrayBuffer, a Document, or null. | With json, bad JSON gives null. It doesn't throw an error in onload. |
responseText | It holds the body as text. | It only works for text. With other types, reading it can throw an error. |
overrideMimeType() | It tells XHR to read the answer as a different data type (MIME type), such as text/plain. Call it before send(). | It doesn't change what the server sent. Use it rarely. |
withCredentials | It sends cookies and login details with a request to another site. | The other site must allow this in its CORS headers. |
Upload progress: what XHR can do that fetch can't
INTERACTIVEUploading means sending data from your browser to a server, such as a photo. Download progress tells you that bytes are arriving. Upload progress tells you that bytes are leaving. XHR has an object just for upload events: xhr.upload.
You are moving house. Boxes go onto a truck one by one. Someone ticks each box off on a clipboard, so you always know how many boxes are on the truck.
- In real life: Boxes go onto the truck one by one
- In JavaScript: Bytes leave the browser
- In real life: A clipboard counts the boxes loaded so far
- In JavaScript:
xhr.upload.onprogressreports the bytes sent so far - In real life: The house at the other end can still refuse the delivery
- In JavaScript: The server can still answer 404 or 405 after the upload events
The demo below sends a made-up text Blob to this page with POST. POST is the HTTP method for sending data. This website doesn't accept uploads, so the server may answer with an error after the upload. That is fine: we only want to watch the upload events.
const xhr = new XMLHttpRequest();xhr.open("POST", location.href);xhr.upload.onprogress = (event) => { const percent = event.lengthComputable ? Math.round((event.loaded / event.total) * 100) : "unknown"; console.log("uploaded", percent);};xhr.onloadend = () => console.log("finished", xhr.status);xhr.send(generatedBlob);Press the button to send a made-up Blob to this page.
Press the button to send a made-up Blob to this page.
xhr.upload object for upload events. fetch() has no standard upload progress events, even though it can read an answer piece by piece.fetch() can show download progress, by reading the answer piece by piece. But it has no standard upload progress events like xhr.upload.onprogress. That is the main reason many upload bars still use XHR.
A progress event has three properties:
loaded: the bytes sent so far.total: the full size in bytes, if it is known.lengthComputable:truewhen the full size is known, andfalsewhen it isn't.
function progressMessage(event) {
if (!event.lengthComputable) return event.loaded + " bytes uploaded";
return Math.round((event.loaded / event.total) * 100) + "% uploaded";
}
console.log(progressMessage({ loaded: 240, total: 400, lengthComputable: true }));
console.log(progressMessage({ loaded: 240, total: 0, lengthComputable: false }));- Line 2: if the total isn't known, return the bytes sent so far.
- Line 3: otherwise, work out the percent and round it.
- Line 6 passes 240 of 400 bytes with the total known, so it prints
60% uploaded. - Line 7 passes 240 bytes with no known total, so it prints
240 bytes uploaded.
Only work out a percent when lengthComputable is true. If you divide by an unknown total, you get a number that looks exact but is wrong. Show the bytes sent instead, or a bar that moves without a percent.
Wrapping XHR in a promise
PRACTICALXHR is older than promises. A promise is an object that will later hold a result or an error. You read it with then, catch, or await.
You can put an XHR request inside a promise. Then the rest of your app uses await, while the inside still listens to XHR events. You saw this idea in the Promisification & promise-based APIs lesson.
function xhrRequest(url, options = {}) { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open(options.method ?? "GET", url, true); xhr.responseType = options.responseType ?? ""; xhr.onload = () => resolve({ status: xhr.status, response: xhr.response }); xhr.onerror = () => reject(new TypeError("Network error")); xhr.ontimeout = () => reject(new DOMException("Timed out", "TimeoutError")); xhr.onabort = () => reject(new DOMException("Aborted", "AbortError")); for (const [name, value] of Object.entries(options.headers ?? {})) { xhr.setRequestHeader(name, value); } xhr.send(options.body ?? null); });}- Line 2 makes a new promise. Calling
resolvegives it a result. Callingrejectgives it an error. - Lines 3 to 5 create the request, open it, and set its
responseType. - Line 6: when
loadfires, the promise gets thestatusand theresponse. - Lines 7 to 9: when
error,timeout, orabortfires, the promise gets an error. - Lines 10 to 12 add each header from
options.headers. - Line 13 sends the request, with a body if there is one.
Look at what this wrapper does with a 404. It still calls resolve, because a 404 fires load. It only calls reject when no answer came: a network error, a timeout, or an abort. So your code still has to check status itself.
function isHttpSuccess(status) {
return status >= 200 && status < 300;
}
console.log(isHttpSuccess(204));
console.log(isHttpSuccess(404));A status from 200 to 299 means success. Line 5 prints true for 204. Line 6 prints false for 404.
So a 404 resolves the wrapper's promise, but fails this check. A network error rejects the promise, and there is no status to check.
All setup goes after open() and before send():
const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/messages", true);
xhr.responseType = "json";
xhr.timeout = 5000;
xhr.withCredentials = true;
xhr.setRequestHeader("Content-Type", "application/json");
xhr.send(JSON.stringify({ text: "Hello from XHR" }));- Line 2 opens a
POSTrequest to/api/messages. - Line 3 asks for the answer as JSON.
- Line 4 sets a time limit of 5000 milliseconds, which is 5 seconds.
- Line 5 sends cookies and login details with the request, with
withCredentials. - Line 6 adds a header that says the body is JSON.
- Line 7 sends the body, a JSON string.
open() has a third argument, async. If you pass false, the request is synchronous: the page stops and waits until the answer arrives. Clicks don't work, and nothing on the screen changes. Browsers have deprecated this on the main page, which means it is old and may be removed. If you see it in old code, change it to the normal way, with async left as true.
When XHR is still useful
SORTNew request code usually starts with fetch(). Here is why:
- It returns a promise, so it works with
await. - It can be cancelled with an
AbortSignal, an object that tells a request to stop. - It can read an answer piece by piece. This is called a stream.
XHR is still useful in three places:
- Upload progress: use
xhr.upload.onprogressfor file upload bars. - Older code: old jQuery request code and older libraries often use XHR inside.
- Finding bugs step by step: the ready states and events show each step of a request, so you can see where it went wrong.
- Show a progress bar while a file uploads
- Read an answer piece by piece as it arrives
- Handle a request inside a service worker
- Keep old jQuery request code working
- Cancel with
AbortSignal - Load JSON from your own site and read it
- Send a request with your own headers
Put each job under the tool you would pick first.
Common misconceptions
- "XHR is gone." It is old, but not gone. Browsers still support it, so that old pages keep working.
- "A 404 fires error." No. A 404 is still an answer, so
loadfires. Checkstatusto see if it worked. - "Status 0 is a real HTTP status." No. It means no answer your code can read. Common causes are an abort, a timeout, a network failure, or CORS.
- "Progress always knows the total." No.
lengthComputablecan befalse. Then show the bytes sent, without a percent. - "responseText always works." No. It only works for text. For other response types, use
response. - "Synchronous XHR is a handy shortcut." No. It freezes the page, and browsers have deprecated it on the main page. Don't use it in new code.
Practice exercises
5 EXERCISESPredict what the program prints.
const states = [0, 1, 2, 3, 4];
const names = ["UNSENT", "OPENED", "HEADERS_RECEIVED", "LOADING", "DONE"];
console.log(names[states.at(-1)]);DONEXHR state 4 is DONE. It means the request has ended, whether it worked or failed.
Predict the event name and the status that this short program prints.
const status = 404;
const event = status >= 400 ? "load" : "load";
console.log(event + " " + status);load 404A 404 answer fires load. Your code then reads status to decide whether the request did what you wanted.
This code has the right lines in the wrong order. Fix the order.
const xhr = new XMLHttpRequest();
xhr.setRequestHeader("Content-Type", "application/json");
xhr.open("POST", "/api/messages");
xhr.send(JSON.stringify({ text: "hi" }));const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/messages");
xhr.setRequestHeader("Content-Type", "application/json");
xhr.send(JSON.stringify({ text: "hi" }));Call open() first, then setRequestHeader(), then send(). XHR needs the method and the URL before it can add headers.
A spinner is a turning icon that means "loading". Write the handler you would add before send(). It should hide the spinner, however the request ends.
const xhr = new XMLHttpRequest();
showSpinner();
xhr.open("GET", location.href);
// Add one handler that hides the spinner for success, error, abort, and timeout.
xhr.send();xhr.onloadend = () => {
hideSpinner();
};loadend fires after load, error, abort, or timeout. So it is the one place to clean up for every case.
Write a progress message that still works when the browser doesn't know the total upload size.
xhr.upload.onprogress = (event) => {
// If total length is known, show a percent.
// Otherwise show raw bytes uploaded.
};xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
const percent = Math.round((event.loaded / event.total) * 100);
console.log(percent + "% uploaded");
} else {
console.log(event.loaded + " bytes uploaded");
}
};Progress events don't always know the total. Good code handles both cases: a percent when the total is known, and the bytes sent when it isn't.
Check your understanding
7 QUESTIONSQuestion 1 of 7In what order do you call the two main XHR methods?
Choose an answer to see the explanation.
Question 2 of 7What does this readyState lookup print?
Read the code, then predictconst states = [0, 1, 2, 3, 4]; const names = ["UNSENT", "OPENED", "HEADERS_RECEIVED", "LOADING", "DONE"]; console.log(names[states.at(-1)]);Choose an answer to see the explanation.
Question 3 of 7A server answers an XHR request with a 404. Which outcome event should your code expect?
Choose an answer to see the explanation.
Question 4 of 7Which event does this list end with?
Read the code, then predictconst events = ["loadstart", "progress", "load", "loadend"]; console.log(events.at(-1));Choose an answer to see the explanation.
Question 5 of 7Why can
xhr.statusbe 0?Choose an answer to see the explanation.
Question 6 of 7Which XHR property do you use for a real upload progress bar?
Choose an answer to see the explanation.
Question 7 of 7When can you read
xhr.responseText?Choose an answer to see the explanation.
Key takeaways
- XHR is old, but browsers still support it, and older code uses it a lot.
- The order is
open(), then any setup and headers, thensend(). readyStategoes from 0 to 4.loadendis always the last event, so clean up there.- A 404 or 500 answer still fires
load. A network failure, an abort, or a timeout fires its own event, andstatususually stays 0. - Use
xhr.upload.onprogresswhen you need real upload progress.
Final definition: XMLHttpRequest is the browser's older object for sending HTTP requests. It tells you what happens through events. It's worth knowing for its ready states, its progress events, and the older code that still uses it.
Up next: Cookies.