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.
- 01Read the lifecycleFollow
open(),send(), readyState changes, and the final event order. - 02Handle success and failure honestlyTell HTTP errors apart from network failures, aborts, and timeouts.
- 03Choose XHR on purposeRecognize upload progress and legacy-code cases where XHR still appears.
Why learn XMLHttpRequest?
XMLHttpRequest, usually shortened to XHR, is the older browser API for making HTTP requests from JavaScript. Modern code often uses fetch, but XHR is still worth knowing because it appears in legacy apps, browser libraries, upload progress widgets, and old examples you will meet at work.
The name sounds huge, but the idea is small: create a request object, configure it, attach event handlers, then send it. Instead of returning a promise, XHR talks back through events and a numeric readyState.
Think of XHR as a rotary phone. It works, it has tactile knobs and dials, and it teaches you what is happening during the call. Fetch is the smartphone: more comfortable for everyday use, especially when your code already thinks in promises.
- In real life: A rotary phone can still call people
- In JavaScript: XHR can still request data in every browser
- In real life: It has dials, clicks, and steps
- In JavaScript: XHR has
readyState,readystatechange, progress, timeout, and abort events - In real life: You find it in older houses
- In JavaScript: You find XHR in older codebases and libraries
- In real life: A smartphone is easier for most new calls
- In JavaScript: fetch is usually cleaner for new request code
Where the analogy stops: A rotary phone and smartphone use different networks; XHR and fetch both use the browser’s networking rules. The analogy is about ergonomics and age, not different internet pipes.
You will run real requests in this lesson, but never to external URLs. The labs request this page, a missing same-origin path, generated blob: URLs, or a generated upload body. That keeps the lesson safe on a static site.
open(method, url, async) prepares the request. setRequestHeader() happens after open() and before send(). send(body) starts the request. Events tell you what happened.
The XHR lifecycle
INTERACTIVEThe lifecycle has two parts: the code you write and the events the browser fires. Your code runs in this order:
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();The important surprise is that open() does not send the request. It stores the method and URL and changes readyState to OPENED. The trip begins 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.
Choose a target, then send a real same-origin XHR.
Try “Missing page.” You should see a real HTTP status like 404, but the final event is still load. That is because XHR separates transport success from application success. A server saying “not found” is still a response. A revoked blob URL, timeout, abort, or CORS failure gives no usable HTTP response and normally leaves status at 0.
readyState and events
STEP THROUGHXHR has five ready states. They are exposed as numbers and as constants on XMLHttpRequest, such as XMLHttpRequest.DONE. People usually write the names in comments or logs because the numbers alone are hard to remember.
A pizza order moves through recognizable stages. XHR does too: not started, opened, headers, loading, done. The names make event logs readable.
- In real life: 0: menu in your hand
- In JavaScript:
UNSENT: the object exists but is not opened - In real life: 1: you dialed the shop
- In JavaScript:
OPENED: method and URL are set - In real life: 2: the shop picked up
- In JavaScript:
HEADERS_RECEIVED: status and headers are known - In real life: 3: the pizza is on the way, slice by slice
- In JavaScript:
LOADING: response bytes are arriving - In real life: 4: delivered, even if the order is wrong
- In JavaScript:
DONE: finished, failed, timed out, or aborted
Where the analogy stops: A pizza shop usually gives one final answer. XHR may fire many progress events during the trip, and a DONE request may still have status 0.
| Value | Constant | What it means | What you can usually read |
|---|---|---|---|
| 0 | UNSENT | Created but not opened | Configuration only |
| 1 | OPENED | Opened but not sent or not answered yet | Configuration only |
| 2 | HEADERS_RECEIVED | Headers and status are available | status and response headers |
| 3 | LOADING | Body is arriving | Partial text in some cases and progress |
| 4 | DONE | The request is complete | status, headers, and response |
The event order is not “one event per ready state.” It is a mix: readystatechange for state changes, loadstart and progress for byte movement, one final outcome event (load, error, abort, or timeout), then loadend for cleanup.
Step through the modeled success order. It matches the browser event shape checked for this lesson: a start, zero or more progress/state changes, one final outcome, 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();Responses, status, and responseType
INTERACTIVEXHR gives you several response tools. status is the numeric HTTP status. statusText is the browser’s text for that status when available. responseURL is the final URL after redirects. getAllResponseHeaders() returns the exposed response headers as one string.
For the body, prefer xhr.response. Its type depends on responseType. responseText is only safe for the default empty string or "text". If you set "json", "blob", "arraybuffer", or "document", read response instead.
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();Run the request to inspect xhr.response.
Run the request to inspect xhr.response.
json becomes null for this HTML page; document parses the HTML into a Document in browsers that support it.| Feature | Use it for | Watch out |
|---|---|---|
responseType | Tell the browser how to expose the body | Set it before the response finishes; not every type is useful for every URL. |
response | The parsed body: string, object, Blob, ArrayBuffer, Document, or null | For json, invalid JSON becomes null instead of throwing in onload. |
responseText | Text bodies only | Reading it with non-text responseType can throw. |
overrideMimeType() | Force a MIME type interpretation before sending | Use sparingly; it does not change what the server actually sent. |
withCredentials | Include cookies/auth on cross-origin requests when allowed | The server must opt in with CORS credentials headers. |
Upload progress: XHR’s superpower
INTERACTIVEDownload progress says “bytes are arriving.” Upload progress says “bytes are leaving.” If you are sending a big file and want a real progress bar, XHR still has a simple event target for it.
- In real life: Boxes being loaded one by one
- In JavaScript: Bytes leaving the browser
- In real life: A clipboard that counts loaded boxes
- In JavaScript:
xhr.upload.onprogress - In real life: The destination may still reject the delivery
- In JavaScript: The server may return 404 or 405 after upload events
Where the analogy stops: A moving truck can always count boxes. Browsers, networks, and local servers may coalesce small uploads so you might see few or no progress events for tiny bodies.
The lab sends a generated text Blob to this same page with POST. A static site might reject the method after the browser tries to upload the body, and that is okay for the lesson: we are watching upload events, not storing a file.
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);Click Upload to send a generated Blob to this same page.
Click Upload to send a generated Blob to this same page.
The modern fetch API can show download progress through response streams, but it does not provide standard upload progress events like xhr.upload.onprogress. That single difference is the main reason many real upload widgets still use XHR.
Wrapping XHR in a promise
PRACTICALXHR was designed before promises were the default style. You can wrap it so the outside of your app uses then, catch, and await, while the inside still listens to XHR events. This is the same idea you learned 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); });}Notice the policy choice: this wrapper resolves for every HTTP response, including 404. It rejects for transport failures: network error, timeout, or abort. That mirrors how XHR events work and keeps status checking explicit.
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" }));XHR has an old async = false mode, but synchronous XHR on the main thread is deprecated because it blocks the page: no clicks, no painting, no helpful feedback. Learn to recognize it in old code, then replace it with asynchronous code.
When XHR is still useful
SORTNew request code usually starts with fetch because it is promise-based and works naturally with AbortSignal, request and response objects, and streams. XHR remains useful in three practical places:
- Upload progress: use
xhr.upload.onprogressfor file upload bars. - Legacy code: older jQuery-style AJAX, older library versions, and browser support layers often wrap XHR.
- Fine-grained lifecycle debugging: ready states and events make old behavior easier to inspect.
- Show a progress bar while a file is being uploaded
- Read a response stream chunk by chunk
- Handle a request inside a service worker
- Maintain legacy jQuery AJAX code
- Cancel with
AbortSignal - Load same-origin JSON and parse it
- Send a request with custom headers
Sort each real-world request job by the API you would reach for first.
Common misconceptions
- “XHR is gone.” It is old, not gone. Browsers still support it because the web depends on old pages continuing to work.
- “404 fires error.” No. HTTP error statuses still fire
load. Checkstatus. - “status 0 means HTTP 0.” No. It means there is no usable HTTP response, often because of abort, timeout, network failure, or CORS.
- “progress always knows the total.” No.
lengthComputablecan be false, so show bytes without a percent. - “responseText always works.” No. For non-text response types, use
response. - “Synchronous XHR is a handy shortcut.” It blocks the page and is deprecated on the main thread. Do not 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, meaning the request finished one way or another.
Predict the event name and status text this simplified program logs.
const status = 404;
const event = status >= 400 ? "load" : "load";
console.log(event + " " + status);load 404A 404 response triggers load; your code reads status to decide whether the app-level request succeeded.
This code has the right pieces in the wrong order. Fix the sequence.
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 URL before it can attach request headers.
Write the handler you would add before send().
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 right shared cleanup point.
Write code for a progress message that still works when the browser does not 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 do not always know the total. A robust UI handles both percent and raw-byte messages.
Check your understanding
7 QUESTIONSQuestion 1 of 7Which pair is the basic XHR send sequence?
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 returns HTTP 404 to an XHR. Which final event should your handler 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 is useful 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 still supported and common in legacy browser code.
- The sequence is
open(), optional configuration and headers, thensend(). readyStatemoves from 0 to 4;loadendis the final cleanup event.- HTTP error statuses fire
load; network failures, aborts, and timeouts use their own events and often leavestatusat 0. - Use
xhr.upload.onprogresswhen you need real upload progress events.
Final definition: XMLHttpRequest is the browser’s older event-based HTTP request object, useful to understand for ready states, progress events, and legacy network code.
Up next: Cookies.