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 forgettingsend()is the classic silent bug.- Status handling is manual: there is no
okflag, and a 404 still firesonload. responseTypecan bejson,text,blob,arraybufferordocument.- 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
| Need | fetch | XMLHttpRequest |
|---|---|---|
| Concise promise API | native | manual callbacks or a wrapper |
| Automatic JSON parsing | res.json() | responseType = 'json' |
| Upload progress | not available | xhr.upload.onprogress |
| Cancellation | AbortController | xhr.abort() |
| Streaming a response | res.body reader | partial responseText reads only |
| Old-browser support | needs a polyfill | native |
// 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.
Related
Bodies, headers and status codes CORS explained
Last refreshed 2026-09-18.