JAVASCRIPT / THE BROWSER: DOM, EVENTS, AND STORAGE
The honest legacy of XMLHttpRequest
Drive an XMLHttpRequest through its readyState machine, check status correctly, and wrap it in a promise so legacy code behaves predictably.
What you will learn
- Read the response inside load or readystatechange, never on the line after send()
- Check xhr.status yourself; error fires only when no HTTP response arrived
- Set responseType, timeout, and listeners before send(); headers only after open()
- Wrap XHR in a promise, and keep it only for upload progress or timeouts
Understanding The honest legacy of XMLHttpRequest
XMLHttpRequest is not a function that fetches something and hands it back; it is an object whose fields you mutate and whose progress you observe. open() only records the method, URL, and async flag and resets the request header list, while send() is what actually hands the request to the network stack, and it returns immediately. Between those two calls the object walks through readyState values (0 unsent, 1 opened, 2 headers received, 3 loading, 4 done), firing a readystatechange event on each transition. The XML in the name is a fossil from its origin as a Microsoft ActiveX control for Outlook Web Access; the object never cared what the bytes were, which is why you receive a string and parse it yourself.
The part that bites people is what counts as an error. The error event fires only when the browser never received an HTTP response at all: DNS failure, refused connection, a request blocked by CORS. A 404 or a 500 is a perfectly successful exchange as far as XHR is concerned, so load fires, readyState reaches 4, and xhr.status holds the number you must inspect. Reaching state 4 means nothing more is coming, not that anything worked, and a status of 0 is the tell that no response existed.
XHR survives for concrete reasons rather than nostalgia: xhr.upload emits progress events with loaded and total bytes while a request body is being sent, which plain fetch cannot report; xhr.timeout gives a per-request deadline with no extra plumbing; and synchronous mode still lurks in old unload handlers. Everything else, including cancellation and composing requests, is cleaner with fetch, because XHR spreads one logical operation across mutable object state and half a dozen events. When you inherit readyState code, the cheapest safe change is to wrap the object in a promise and move the logic into load and error handlers, so callers get one success path and one failure path instead of a state machine.
// A blob URL gives a real HTTP-shaped response without needing a server.
const payload = '{"id":7,"name":"Ada"}';
const url = URL.createObjectURL(new Blob([payload], { type: 'application/json' }));
const xhr = new XMLHttpRequest();
const seen = [];
xhr.onreadystatechange = function () {
seen.push(xhr.readyState);
if (xhr.readyState !== 4) return;
console.log('states:', seen.join(' -> '));
console.log('status:', xhr.status);
console.log('content-type:', xhr.getResponseHeader('Content-Type'));
console.log('name:', JSON.parse(xhr.responseText).name);
URL.revokeObjectURL(url);
};
xhr.open('GET', url); // fires readystatechange with readyState 1, synchronously
xhr.send();
console.log('after send(), readyState is', xhr.readyState);Reaching readyState 4 only means the request stopped; XMLHttpRequest reports success through a status code that you have to check yourself.
Worked examples
Events instead of readyState
Shows the modern way to use XHR: load, error, and timeout listeners plus responseType, with no state numbers anywhere.
const url = URL.createObjectURL(
new Blob(['[10,20,30]'], { type: 'application/json' })
);
const xhr = new XMLHttpRequest();
xhr.open('GET', url);
xhr.responseType = 'json';
xhr.timeout = 2000;
xhr.addEventListener('load', () => {
console.log('array?', Array.isArray(xhr.response), 'second:', xhr.response[1]);
try {
console.log('text:', xhr.responseText);
} catch (err) {
console.log('responseText threw', err.name);
}
URL.revokeObjectURL(url);
});
xhr.addEventListener('error', () => console.log('no response arrived'));
xhr.addEventListener('timeout', () => console.log('deadline passed'));
xhr.send();Example explained
Line 1responseType = 'json' makes the browser parse the body, so xhr.response is a real array rather than a string you parse.
Line 2responseText throws InvalidStateError once responseType is anything but '' or 'text', because the text view and the parsed view are mutually exclusive.
Line 3The load listener runs for any completed exchange, so a 404 would also land here with status 404 and no error event.
Line 4timeout is a separate event from error, so a request that never answers and a request that answered badly are three different branches, not one.
Promisifying an old request
Wraps a single XHR in a promise so the status check happens once and callers only see resolve or reject.
function getText(url) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', url);
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) resolve(xhr.responseText);
else reject(new Error('HTTP ' + xhr.status));
};
xhr.onerror = () => reject(new Error('connection failed'));
xhr.send();
});
}
const url = URL.createObjectURL(new Blob(['hello from a blob'], { type: 'text/plain' }));
getText(url).then(text => {
console.log('resolved:', text.toUpperCase());
URL.revokeObjectURL(url);
});
console.log('request started');Example explained
Line 1resolve is called from inside onload because send() has no return value to hand back.
Line 2The status range check lives in onload, so a 500 rejects instead of resolving with an error page as if it were data.
Line 3onerror covers the case where no HTTP response arrived, which stops the promise from hanging forever.
Line 4'request started' prints first, proving that send() returns long before the response exists.
Important notes
readyState 3 is not a single event: it fires roughly every 50 ms as bytes arrive, so a large text response can trigger it many times while responseText grows.
Synchronous XHR (passing false as the third argument to open) blocks the tab and is deprecated on the main thread; browsers log a warning, and only Worker code has a defensible use for it.
Common mistakes
Treating readyState === 4 as success: the HTML body of a 404 page goes into JSON.parse and you get a SyntaxError instead of a useful message about the missing resource.
Calling xhr.setRequestHeader before xhr.open: it throws InvalidStateError, because open() is what creates the empty header list the header would be added to.
Reading xhr.responseText on the line right after send(): it is an empty string because the request has barely left, and people conclude the server returned nothing.
Try it yourself
Change, predict, then run
In a browser console, build a blob URL containing a small JSON array, request it with XHR using responseType 'json', and log every readyState value you observe together with the final status. Then delete the responseType line, log responseText instead, and note which of the two reads is legal in each version.
Open the JavaScript workspaceCheck your understanding
A GET request finishes with HTTP 500 and an HTML error page in the body. What does XMLHttpRequest do?
- The error event fires, because a 500 means the request failed
- readystatechange stops at readyState 3, so no completion handler runs
- The load event fires with status 500 and the HTML in responseText; only your own check makes it a failure
- The timeout event fires as soon as the server reports the error
Show answer
XHR counts any completed HTTP exchange as a success, so load runs, readyState reaches 4, and status carries the 500. The error event is reserved for cases where no response arrived at all, such as a refused connection, a DNS failure, or a CORS block, which is why expecting error to catch a 500 lets server errors flow silently into your parsing code.