XMLHttpRequest and the gaps in fetch

The original transport API, upload progress with xhr.upload, and an honest comparison of when XMLHttpRequest is still the right tool.

The original API

function get(url, onDone, onFail) {
  const xhr = new XMLHttpRequest();
  xhr.open('GET', url);
  xhr.responseType = 'json';
  xhr.timeout = 8000;
  xhr.setRequestHeader('Accept', 'application/json');

  xhr.onload = () => (xhr.status >= 200 && xhr.status < 300)
    ? onDone(xhr.response)
    : onFail(new Error('HTTP ' + xhr.status));
  xhr.onerror = () => onFail(new Error('network'));
  xhr.ontimeout = () => onFail(new Error('timeout'));

  xhr.send();
}
  • open() only prepares the request; send() starts it, and forgetting send() is the classic silent bug.
  • Status handling is manual: there is no ok flag, and a 404 still fires onload.
  • responseType can be json, text, blob, arraybuffer or document.
  • Everything here has a fetch equivalent, so prefer fetch unless you need one of the capabilities below.

Upload progress

const xhr = new XMLHttpRequest();

xhr.upload.onprogress = event => {
  if (event.lengthComputable) {
    bar.value = Math.round((event.loaded / event.total) * 100);
  }
};
xhr.upload.onload = () => console.log('sent');
xhr.onload = () => console.log('response', xhr.status);

xhr.open('POST', '/upload');
xhr.send(file);
💡
Download progress is available in fetch through res.body and a ReadableStream reader, but upload progress has no equivalent. An XMLHttpRequest with xhr.upload is still the straightforward way to drive a progress bar.

Choosing between them

NeedfetchXMLHttpRequest
Concise promise APInativemanual callbacks or a wrapper
Automatic JSON parsingres.json()responseType = 'json'
Upload progressnot availablexhr.upload.onprogress
CancellationAbortControllerxhr.abort()
Streaming a responseres.body readerpartial responseText reads only
Old-browser supportneeds a polyfillnative
// both transports cancel the same way from the caller's point of view
xhr.send();
xhr.abort();                     // fires onabort, then onloadend

const ac = new AbortController();
fetch(url, { signal: ac.signal }).catch(err => {
  if (err.name !== 'AbortError') throw err;
});
ac.abort();
  • Use one wrapper function per project and keep the transport choice inside it; mixing both in one module makes cancellation and error handling inconsistent.
  • Anything that needs a byte-level upload progress indicator is the remaining everyday reason to choose XMLHttpRequest.
  • Server-Sent Events use EventSource, not either of these, and are the right choice for a one-way stream from the server.

FAQ

Is XMLHttpRequest deprecated?
No. It is still specified and maintained, and polyfills for fetch are built on it. New code normally uses fetch because of the promise API and the streaming body.
Which one should I use in a legacy codebase?
Match the surrounding code. Mixing the two transports inside one feature makes cancellation, retries and error reporting behave differently depending on which call site you are looking at.

Bodies, headers and status codes CORS explained

Last refreshed 2026-09-18.