Learning Objectives
- Name the arguments
opentakes, and say whatopendoes not do - Place
setRequestHeadercorrectly, and say what happens when you place it wrong - Read the output of
getAllResponseHeaders(), and name the headers that are missing from it and why - Give the five
readyStatevalues, and say which one still earns a check - Write the same request with
load,error,timeout,abort, andloadendinstead - Say what
status === 0means and name its three causes - Set
xhr.timeout, callxhr.abort(), and bridge anAbortSignalto both by hand - Report upload progress from
xhr.upload, and say whyfetchhas no portable equivalent
The object surface
XMLHttpRequest has a small surface: six methods and about a dozen properties. Several of them are only legal at particular moments. Get the order wrong and the object throws.
open configures. It does not open anything.
open('GET', url, true) sets the method, the URL, and whether the request is asynchronous. Nothing goes on the network. The name comes from a design that was once closer to a socket.
The full signature takes five arguments: open(method, url, async, username, password). The last two are HTTP Basic credentials. Don't use them. They put a password in your JavaScript and they only reach servers still doing Basic authentication.
The third argument defaults to true. Passing false makes the request synchronous, which blocks the main thread until the server answers. It is deprecated, it still runs, and current browsers warn about it in the console. Don't write it.
setRequestHeader has one legal window
After open, before send. Outside that window it throws InvalidStateError.
Two behaviors catch people out. Calling it twice with the same header name appends rather than replaces: two calls setting Accept produce one header carrying both values, joined by a comma and a space. And a list of header names is forbidden to script, including Host, Connection, Content-Length, Origin, Referer, Cookie, and anything starting with Proxy- or Sec-. Setting one of those does not throw. The call returns, the header is not set, and the browser prints a refusal in the console, where nobody is looking. The demo below does this on purpose.
send is the only line that touches the network
send(body) puts the request on the wire and returns immediately, usually before the server has heard of it. GET and HEAD ignore the body argument. What belongs in that argument is step 06.
Reading headers back
getResponseHeader(name) is case-insensitive and returns null when the header is absent. getAllResponseHeaders() returns the whole set as one string. Lines are separated by CRLF and names come back lowercased, whatever case the server used.
It is a string, not an object, so you parse it yourself.
Headers are missing from that list even same-origin. Set-Cookie is never handed to script in any browser, so a cookie the server just set is invisible to the code that asked for it. Cross-origin the list collapses to seven names: Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified, and Pragma, plus whatever the server chooses to name in Access-Control-Expose-Headers. The demo below runs same-origin and prints everything it is allowed to. Step 08 runs the same call against a second origin and prints the short version.
responseType decides what you get back
Set xhr.responseType = 'json' and the browser parses the body for you. Your code reads xhr.response and there is no JSON.parse call anywhere in it. A body that will not parse yields null instead of throwing, which moves that failure from a try block to an if. MDN puts responseType at Baseline widely available, in every major browser since November 2016.
Setting it has one consequence to know about in advance. responseText then throws InvalidStateError. responseText is readable only when responseType is '' or 'text'. Any library reading responseText behind your back breaks the moment you set the property. The demo below runs into this and says so on the page.
readyState, and why you stopped needing it
readyState reports where the object is. Five values.
0UNSENT. Constructed.openhas not been called.1OPENED.openhas been called.setRequestHeaderis legal from here untilsend.2HEADERS_RECEIVED. The status line and the headers have arrived.statusandgetAllResponseHeaders()are readable.3LOADING. The body is arriving.readystatechangefires repeatedly whilereadyStatesits here, once for each chunk the browser hands over.4DONE. The exchange is over. Success and every kind of failure both land here.
onreadystatechange fires on every transition, so most of the calls have nothing in them for you. So every example from the era opens by checking for 4 and returning.
XMLHttpRequest Level 2 replaced the arrangement with named events.
loadstartfires once, whensendis called.progressfires while the response body arrives, withloadedandtotalattached. Same information as state3, with numbers.loadfires when the exchange completed. Any status.errorfires when it did not complete: DNS failure, connection refused, a cross-origin block.timeoutfires whenxhr.timeoutelapsed first.abortfires when your code calledxhr.abort().loadendfires after whichever of the previous four ended the exchange. Always. Cleanup goes here.
Exactly one of load, error, timeout, and abort fires per request, then loadend. The readyState version has to reach state 4, read status, and infer which of the four happened. The events say so directly.
None of this is new. Internet Explorer 10, released in 2012, was the last major browser to add these events. Everything else had them earlier.
One place readyState still earns a check
State 2. The status line and the headers have landed and the body has not. Read Content-Length or Content-Type there, decide you do not want the response, and call abort() before the body crosses the wire. That is a real technique for large downloads and a rare one. readyState is not deprecated. Read it when you need it. Don't build control flow on it.
status is not success
load fired. The exchange completed. Nothing more.
A 404 fires load. A 500 fires load. A 403 fires load. The server answered in every one of those cases, and an answer is what load is for. Checking the status is a separate line you have to write.
status === 0 confuses everyone once. No server sends 0. It means no status line was ever handed to your code, and there are three ways to arrive there.
- You called
abort(). Your own code canceled the request. - The network failed. DNS did not resolve, the connection was refused, TLS did not negotiate, the machine went offline.
- The browser blocked a cross-origin read. The request may have been sent and answered. You are not allowed to see any of it. The reason is printed in the console and your script cannot read that either.
The events separate two of them: abort for the first, error for the other two. A cross-origin block presents as a network failure. From the page's side, it is one. Step 08 is about living with that.
statusText carries the reason phrase over HTTP/1.1, OK or Not Found. HTTP/2 and HTTP/3 removed the reason phrase from the protocol, so over those connections it is an empty string. This site is served over HTTP/2. The demo below prints the value quoted, and it reads "". Do not build anything on it.
fetch looks at the same facts and draws its success line somewhere else. Step 05 argues that out.
Timeouts and abort
With no timeout, a request waits as long as the connection stays open. xhr.timeout is a number of milliseconds and defaults to 0, which means no limit. Set it whenever you like. The object has no ordering rule for it, and open does not reset it. Old Internet Explorer did require it after open. Most code still puts it there out of habit.
When it elapses the object fires timeout, then loadend, and status is 0. The server may still be working on the request. A client timeout tells the server nothing.
Setting timeout on a synchronous request in a document throws InvalidAccessError. You are not writing synchronous requests, so this will not come up.
xhr.abort() stops the request. It fires abort, then loadend, and leaves status at 0. Calling it on an object that has not been sent does nothing and fires nothing.
A user can press a control faster than a server can answer, and two answers in flight can arrive in either order. Holding a reference to the request and aborting it before starting the next one is the smallest correct fix, and it is what the step 03 demo was already doing. Step 07 does it properly.
XMLHttpRequest shipped with both of these. fetch shipped with neither. AbortController reached the last of the three major engines in 2019 and gave fetch cancellation. AbortSignal.timeout() reached all three in 2022 and gave it a deadline. Step 05 covers both.
Upload progress
xhr.upload is a second event target hanging off the same object. It fires the same event names for the request body going out that xhr fires for the response coming in. Listen on xhr for the download, on xhr.upload for the upload.
A progress event carries three values. lengthComputable is true when the total is known. loaded is the bytes sent so far. total is the size of the whole body. When lengthComputable is false, total is 0 and you have no denominator to divide by.
How many events you get depends on the connection. The XMLHttpRequest standard puts a ceiling of roughly one event every 50 milliseconds and sets no floor at all. An 8 MB body to a server on the same machine finishes in a single event with loaded === total. A 20 MB file over hotel wifi fires hundreds. Write the handler so that one event is a valid run.
What fetch can and cannot do here
fetch handles download progress fine. response.body is a ReadableStream, and counting bytes as you read it gives you the same numbers. Upload is the gap, and it is the reason XMLHttpRequest is still on the table in 2026.
Sending a ReadableStream as a request body requires the duplex: 'half' option. That shipped in Chromium 105 and exists in no other engine. Firefox has had the bug open since 2017. Safari accepts a stream on a Request object and then refuses to send it through fetch.
Even in Chromium the restrictions rule out most of the cases you would want it for: HTTPS only, HTTP/2 or HTTP/3 only, always preflighted, and only 303 redirects are followed. What you would be counting is bytes your code handed to the browser, not bytes that reached the server. Those two numbers separate as soon as anything buffers.
If a user is uploading something big enough that a progress bar matters, use XMLHttpRequest.
Demo: the same feature with the instruments on
The step 03 demo, rewritten. Every event the object fires is written to a log in the order it fires. responseType is 'json', so nothing on the page calls JSON.parse. getAllResponseHeaders() is dumped verbatim. A second control posts a large body so xhr.upload has something to report. The instrumentation strip carries over from steps 02 and 03, with one field added: which event ended the last request.
Four things to run, in any order.
- Make
timeoutfire. Choose/api/slow?ms=5000, set the timeout to1000, press Send. The log showsloadstart,timeout,loadend. Noload, noerror. - Make
abortfire. Choose/api/slow?ms=5000with the timeout back at0, press Send, then press Abort it. The panel at the bottom records the request with status0, the same value the timeout left. - Watch the upload. Press POST it and read the progress event counter. One event is a valid answer, and on a fast connection it is the usual one. Set the DevTools network throttle to a slow profile and press it again to see the count go into the dozens.
- Read
statusText. The demo prints it quoted, under the header dump, next to the protocol the response arrived on. Here it reads""overh2. Run the same page underwrangler dev, which speaks HTTP/1.1, and it reads"OK". Same server, same handler, same status code.
The header dump is the same-origin case from the section above. Count the lines, then remember the number when step 08 makes the same call across origins.
Two lines in that demo exist for the demo
The upload sets Referer, which is forbidden, so you can watch the call return quietly and the real header go out anyway. And the page defines its own responseText on each object, because the capture panel at the bottom reads responseText when a request ends and responseType = 'json' makes that throw. The demo's own callout explains both. Don't copy either one.
What to actually write
If something stops you from using fetch, this is the XMLHttpRequest to write.
Five differences from step 03's version. Events instead of onreadystatechange. responseType = 'json' instead of JSON.parse. A timeout with a real number in it. One AbortController holding every listener, so the whole set comes off in a single call. And an AbortSignal bridged by hand, because the object predates it.
A 404 resolves that promise. A dropped connection rejects it. Hold on to that shape for step 05. fetch makes the same call and takes constant criticism for it.
Then stop writing it. fetch does this in fewer lines and composes with await. Step 05 writes the same function again and then spends the rest of the step on the decision everyone trips over.
Next Steps
Step 5: fetch, and what changed puts the two side by side and takes the argument that step 04 kept deferring:
- The same request in
fetch, next to theXMLHttpRequestabove - Why
fetchresolves on 404 and 500, and rejects only when the exchange did not complete response.ok, and where the line between an error and data actually belongsAbortSignal.timeout()andAbortSignal.any(): a deadline and a cancel, combined without either one knowing about the otherRequestandResponseas objects you can construct, pass around, and clone, and the body you can only read once- What
fetchstill does not do