Step 04

XHR up close

Step 03 used XMLHttpRequest the way 2005 used it: onreadystatechange, a check for readyState === 4, and JSON.parse on responseText. That code runs. Nobody has written it on purpose in a long time. This step goes through the object a piece at a time and names what replaced each one. Some of it you will still use. Most of it is in code somebody will ask you to maintain.

Learning Objectives

  • Name the arguments open takes, and say what open does not do
  • Place setRequestHeader correctly, 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 readyState values, and say which one still earns a check
  • Write the same request with load, error, timeout, abort, and loadend instead
  • Say what status === 0 means and name its three causes
  • Set xhr.timeout, call xhr.abort(), and bridge an AbortSignal to both by hand
  • Report upload progress from xhr.upload, and say why fetch has 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.

const xhr = new XMLHttpRequest(); xhr.setRequestHeader('Accept', 'application/json'); // InvalidStateError. The object is in state 0; setRequestHeader needs state 1. xhr.open('GET', '/api/posts', true); xhr.send(); xhr.setRequestHeader('X-Too-Late', 'nope'); // InvalidStateError again. The send() flag is set.

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.

cache-control: no-store content-encoding: gzip content-type: application/json; charset=utf-8 transfer-encoding: chunked

It is a string, not an object, so you parse it yourself.

function responseHeaders(xhr) { const raw = xhr.getAllResponseHeaders().trim(); if (raw === '') return {}; return Object.fromEntries(raw.split(/[\r\n]+/).map((line) => { const at = line.indexOf(': '); return [line.slice(0, at), line.slice(at + 2)]; })); }

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.

const xhr = new XMLHttpRequest(); // Configures the object. Nothing has left the machine. xhr.open('GET', '/api/posts?limit=5', true); // Legal here and nowhere else: after open, before send. xhr.setRequestHeader('Accept', 'application/json'); // The browser parses the body. Reading responseText now throws. xhr.responseType = 'json'; xhr.addEventListener('load', () => { console.log(xhr.status, xhr.statusText); console.log(xhr.getResponseHeader('Content-Type')); console.log(xhr.getAllResponseHeaders()); console.log(xhr.response); }); xhr.send();

readyState, and why you stopped needing it

readyState reports where the object is. Five values.

  • 0 UNSENT. Constructed. open has not been called.
  • 1 OPENED. open has been called. setRequestHeader is legal from here until send.
  • 2 HEADERS_RECEIVED. The status line and the headers have arrived. status and getAllResponseHeaders() are readable.
  • 3 LOADING. The body is arriving. readystatechange fires repeatedly while readyState sits here, once for each chunk the browser hands over.
  • 4 DONE. 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.

  • loadstart fires once, when send is called.
  • progress fires while the response body arrives, with loaded and total attached. Same information as state 3, with numbers.
  • load fires when the exchange completed. Any status.
  • error fires when it did not complete: DNS failure, connection refused, a cross-origin block.
  • timeout fires when xhr.timeout elapsed first.
  • abort fires when your code called xhr.abort().
  • loadend fires 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.

const xhr = new XMLHttpRequest(); xhr.open('GET', '/api/posts?limit=5', true); xhr.responseType = 'json'; xhr.timeout = 8000; xhr.addEventListener('load', () => render(xhr.response)); xhr.addEventListener('error', () => report('the request never completed')); xhr.addEventListener('timeout', () => report('eight seconds, no answer')); xhr.addEventListener('abort', () => report('canceled')); xhr.addEventListener('loadend', () => setBusy(false)); setBusy(true); xhr.send();

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.

xhr.addEventListener('load', () => { // Getting here means the exchange finished. It does not mean 200. if (xhr.status >= 200 && xhr.status < 300) { render(xhr.response); } else { report('the server answered ' + xhr.status); } });

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.

xhr.upload.addEventListener('progress', (event) => { if (!event.lengthComputable) return; const percent = Math.round((event.loaded / event.total) * 100); bar.style.width = percent + '%'; bar.setAttribute('aria-valuenow', String(percent)); });

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 timeout fire. Choose /api/slow?ms=5000, set the timeout to 1000, press Send. The log shows loadstart, timeout, loadend. No load, no error.
  • Make abort fire. Choose /api/slow?ms=5000 with the timeout back at 0, press Send, then press Abort it. The panel at the bottom records the request with status 0, 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 "" over h2. Run the same page under wrangler 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.

function request(method, url, options = {}) { const { body = null, headers = {}, timeout = 8000, signal = null } = options; return new Promise((resolve, reject) => { if (signal && signal.aborted) { reject(new Error('abort')); return; } const xhr = new XMLHttpRequest(); // One controller owns every listener attached below. const listeners = new AbortController(); const opts = { signal: listeners.signal }; xhr.open(method, url, true); for (const [name, value] of Object.entries(headers)) { xhr.setRequestHeader(name, value); } xhr.responseType = 'json'; xhr.timeout = timeout; xhr.addEventListener('load', () => resolve({ ok: xhr.status >= 200 && xhr.status < 300, status: xhr.status, data: xhr.response, }), opts); xhr.addEventListener('error', () => reject(new Error('network')), opts); xhr.addEventListener('timeout', () => reject(new Error('timeout')), opts); xhr.addEventListener('abort', () => reject(new Error('abort')), opts); // One call takes all five listeners back off the object. xhr.addEventListener('loadend', () => listeners.abort(), opts); // XMLHttpRequest predates AbortSignal and knows nothing about it. if (signal) { signal.addEventListener('abort', () => xhr.abort(), { once: true, signal: listeners.signal, }); } xhr.send(body); }); }

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 the XMLHttpRequest above
  • Why fetch resolves 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 belongs
  • AbortSignal.timeout() and AbortSignal.any(): a deadline and a cancel, combined without either one knowing about the other
  • Request and Response as objects you can construct, pass around, and clone, and the body you can only read once
  • What fetch still does not do