Skip to main content

How to Use XMLHttpRequest for Network Requests in JavaScript

Introduction

Before fetch() existed, there was XMLHttpRequest. For over fifteen years, XHR was the only way to make HTTP requests from JavaScript without reloading the page. It powered the AJAX revolution that transformed the web from static pages into dynamic applications. Gmail, Google Maps, and countless other applications that made the web feel like a desktop experience all relied on XMLHttpRequest under the hood.

Today, fetch() has replaced XHR as the recommended API for new code. So why dedicate an entire article to an older API? There are three compelling reasons.

First, legacy code is everywhere. Millions of production applications, libraries, and codebases still use XHR. If you work on any project that was built before 2017 (or uses libraries from that era), you will encounter XHR and need to understand it.

Second, upload progress tracking. As of now, fetch() does not support monitoring the progress of request body uploads. If you need to show a progress bar while uploading a large file to a server, XHR's upload.onprogress event remains the most straightforward solution.

Third, understanding history makes you a better developer. Knowing why fetch() was designed the way it was (Promises instead of callbacks, streams instead of response types, a clean separation of concerns) becomes clear when you see the problems it was built to solve.

In this guide, you will learn how to create and send requests with XHR, handle responses in different formats, use the full event system including upload progress, and understand when XHR still makes sense versus when fetch() is the better choice.

Why Learn XHR in the Age of Fetch?

Legacy Codebases

A significant portion of JavaScript code in production today was written when XHR was the standard. jQuery's $.ajax(), AngularJS's $http, and many custom API wrappers are all built on XHR. When maintaining or migrating these codebases, understanding XHR is not optional.

// You WILL encounter code like this
$.ajax({
url: '/api/users',
method: 'GET',
success: function(data) {
renderUsers(data);
},
error: function(xhr, status, error) {
showError(error);
}
});

// Under the hood, jQuery uses XMLHttpRequest

Upload Progress

This is the single most important technical reason to know XHR. The Fetch API provides download progress through ReadableStream, but it has no built-in mechanism for tracking upload progress. XHR does:

// Only XHR can do this natively
xhr.upload.onprogress = function(event) {
const percent = (event.loaded / event.total) * 100;
progressBar.style.width = percent + '%';
};

Libraries That Use XHR

Popular libraries like Axios use XHR internally (in browser environments) specifically because of upload progress support. Understanding XHR helps you understand how these libraries work and debug issues when they arise.

Creating and Sending Requests

Making a request with XHR involves three steps: create the object, configure it, and send it.

Step 1: Create the XHR Object

const xhr = new XMLHttpRequest();

This creates a new request object in its initial state. No request has been configured or sent yet.

Step 2: Configure with open()

The open() method configures the request but does not send it:

xhr.open(method, url, async, username, password);
ParameterRequiredDescription
methodYesHTTP method: 'GET', 'POST', 'PUT', 'DELETE', etc.
urlYesThe URL to request
asyncNotrue (default) for async, false for synchronous (deprecated)
usernameNoUsername for HTTP authentication
passwordNoPassword for HTTP authentication
const xhr = new XMLHttpRequest();

// GET request
xhr.open('GET', 'https://api.example.com/users');

// POST request
xhr.open('POST', 'https://api.example.com/users');

// Always use async (true): synchronous XHR is deprecated
xhr.open('GET', '/api/data', true);
caution

Never use synchronous XHR (async = false). It blocks the entire browser UI thread, freezing the page until the response arrives. Modern browsers show deprecation warnings for synchronous XHR on the main thread, and many contexts (like Service Workers) forbid it entirely.

// WRONG: Blocks the entire page
xhr.open('GET', '/api/data', false);
xhr.send();
// Page is frozen until response arrives!

// CORRECT: Use async (default)
xhr.open('GET', '/api/data'); // async is true by default

Step 3: Send with send()

The send() method dispatches the request. For GET requests, call it with no arguments. For POST/PUT/PATCH requests, pass the request body:

// GET request - no body
xhr.send();

// POST with JSON body
xhr.send(JSON.stringify({ name: 'Alice', age: 30 }));

// POST with FormData
xhr.send(new FormData(formElement));

// POST with plain text
xhr.send('Hello, server!');

// POST with URL-encoded data
xhr.send('username=alice&password=secret');

Setting Request Headers

Use setRequestHeader() after open() but before send():

const xhr = new XMLHttpRequest();
xhr.open('POST', 'https://api.example.com/users');

// Set headers between open() and send()
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.setRequestHeader('Authorization', 'Bearer token123');
xhr.setRequestHeader('X-Custom-Header', 'custom-value');

xhr.send(JSON.stringify({ name: 'Alice' }));

Important rules for setRequestHeader:

  • Must be called after open() and before send()
  • Calling it multiple times with the same header appends values (does not replace)
  • Certain headers (like Host, Origin, Cookie) are browser-controlled and cannot be set
// Multiple calls APPEND, not replace
xhr.setRequestHeader('Accept', 'application/json');
xhr.setRequestHeader('Accept', 'text/plain');
// Result: Accept: application/json, text/plain

Complete Basic Example

Putting all three steps together:

const xhr = new XMLHttpRequest();

// Step 1 & 2: Create and configure
xhr.open('GET', 'https://jsonplaceholder.typicode.com/users/1');

// Handle the response
xhr.onload = function() {
if (xhr.status === 200) {
const user = JSON.parse(xhr.responseText);
console.log(user.name); // "Leanne Graham"
} else {
console.error('Request failed:', xhr.status, xhr.statusText);
}
};

// Handle network errors
xhr.onerror = function() {
console.error('Network error occurred');
};

// Step 3: Send
xhr.send();

POST Example with JSON

const xhr = new XMLHttpRequest();
xhr.open('POST', 'https://jsonplaceholder.typicode.com/posts');

xhr.setRequestHeader('Content-Type', 'application/json');

xhr.onload = function() {
if (xhr.status === 201) {
const createdPost = JSON.parse(xhr.responseText);
console.log('Created post with ID:', createdPost.id);
}
};

xhr.onerror = function() {
console.error('Network error');
};

xhr.send(JSON.stringify({
title: 'My New Post',
body: 'This is the content.',
userId: 1
}));

Response Types and Handling

XHR provides several ways to access the response data, controlled by the responseType property.

The responseType Property

Set responseType after open() but before send() to tell XHR how to parse the response:

const xhr = new XMLHttpRequest();
xhr.open('GET', url);
xhr.responseType = 'json'; // Set before send()
xhr.send();

Available values:

responseTypexhr.response TypeUse Case
'' (default)stringSame as 'text'
'text'stringPlain text, HTML, CSV
'json'object (parsed JSON)API responses
'blob'BlobImages, files, downloads
'arraybuffer'ArrayBufferBinary data processing
'document'Document (XML/HTML DOM)XML/HTML parsing

responseType: '' or 'text' (Default)

The response is available as a raw string:

const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://jsonplaceholder.typicode.com/users/1');
xhr.responseType = 'text'; // or omit (default)

xhr.onload = function() {
console.log(typeof xhr.responseText); // "string"
console.log(xhr.responseText);
// '{"id":1,"name":"Leanne Graham",...}'

// Must parse manually
const user = JSON.parse(xhr.responseText);
console.log(user.name);
};

xhr.send();

responseType: 'json'

XHR automatically parses the response as JSON. No need for JSON.parse():

const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://jsonplaceholder.typicode.com/users/1');
xhr.responseType = 'json';

xhr.onload = function() {
// Already parsed - xhr.response is a JavaScript object
const user = xhr.response;
console.log(typeof user); // "object"
console.log(user.name); // "Leanne Graham"
console.log(user.email); // "Sincere@april.biz"

// Note: xhr.responseText is NOT available when responseType is 'json'
// console.log(xhr.responseText); // Error!
};

xhr.send();
note

When responseType is set to anything other than '' or 'text', the responseText property throws an error. Use xhr.response instead, which works with all response types.

responseType: 'blob'

The response is a Blob object, perfect for downloading files or loading images:

const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://picsum.photos/300/200');
xhr.responseType = 'blob';

xhr.onload = function() {
const blob = xhr.response;
console.log(blob.size); // File size in bytes
console.log(blob.type); // "image/jpeg" or similar

// Display the image
const img = document.createElement('img');
img.src = URL.createObjectURL(blob);
document.body.appendChild(img);

// Clean up the object URL after the image loads
img.onload = () => URL.revokeObjectURL(img.src);
};

xhr.send();

responseType: 'arraybuffer'

The response is an ArrayBuffer for low-level binary processing:

const xhr = new XMLHttpRequest();
xhr.open('GET', '/api/binary-data');
xhr.responseType = 'arraybuffer';

xhr.onload = function() {
const buffer = xhr.response;
const bytes = new Uint8Array(buffer);

console.log(`Received ${bytes.length} bytes`);
console.log(`First byte: ${bytes[0]}`);
};

xhr.send();

responseType: 'document'

Parses the response as an XML or HTML document, creating a DOM that you can query:

const xhr = new XMLHttpRequest();
xhr.open('GET', '/data/feed.xml');
xhr.responseType = 'document';

xhr.onload = function() {
const doc = xhr.response;
const items = doc.querySelectorAll('item');

items.forEach(item => {
const title = item.querySelector('title').textContent;
console.log(title);
});
};

xhr.send();

Response Properties Reference

xhr.onload = function() {
// Status
console.log(xhr.status); // 200 (HTTP status code)
console.log(xhr.statusText); // "OK" (HTTP status message)

// Response data
console.log(xhr.response); // Parsed response (type depends on responseType)
console.log(xhr.responseText); // Raw text (only for '' or 'text' responseType)
console.log(xhr.responseURL); // Final URL after redirects
console.log(xhr.responseXML); // Parsed XML document (only for 'document' or XML responses)

// Response headers
console.log(xhr.getResponseHeader('Content-Type'));
// "application/json; charset=utf-8"

console.log(xhr.getAllResponseHeaders());
// "content-type: application/json; charset=utf-8\r\n
// cache-control: max-age=43200\r\n..."
};

Parsing Response Headers

getAllResponseHeaders() returns all headers as a single string. Here is how to parse them into a usable format:

function parseHeaders(headerString) {
const headers = {};

if (!headerString) return headers;

headerString.split('\r\n').forEach(line => {
if (!line) return;
const colonIndex = line.indexOf(':');
const name = line.substring(0, colonIndex).trim().toLowerCase();
const value = line.substring(colonIndex + 1).trim();
headers[name] = value;
});

return headers;
}

xhr.onload = function() {
const headers = parseHeaders(xhr.getAllResponseHeaders());
console.log(headers['content-type']); // "application/json; charset=utf-8"
console.log(headers['cache-control']); // "max-age=43200"
};

Events: load, error, progress, readystatechange

XHR uses an event-based model for tracking request lifecycle. There are two ways to attach handlers: event properties (onload, onerror) and addEventListener.

The XHR Event Lifecycle

xhr.open()

xhr.send()

├── loadstart (request begins)

├── progress (data received, may fire multiple times)
├── progress
├── progress

├── One of:
│ ├── load (successful completion)
│ ├── error (network failure)
│ ├── abort (request cancelled)
│ └── timeout (time limit exceeded)

└── loadend (request finished, regardless of outcome)

load: Successful Completion

Fires when the request completes successfully. Note that "successfully" means the HTTP round-trip finished, even if the status code is 404 or 500. The same behavior as fetch().

const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://jsonplaceholder.typicode.com/users');
xhr.responseType = 'json';

xhr.onload = function() {
// Always check status - load fires for ALL completed requests
if (xhr.status >= 200 && xhr.status < 300) {
console.log('Success:', xhr.response);
} else {
console.error('Server error:', xhr.status, xhr.statusText);
}
};

xhr.send();

error: Network Failure

Fires when the request fails at the network level (DNS failure, no internet, CORS blocked, server unreachable). Does not fire for HTTP errors like 404 or 500.

xhr.onerror = function() {
console.error('Network error - could not connect to server');
// xhr.status is 0 for network errors
// xhr.responseText is empty
};

timeout: Time Limit Exceeded

If you set a timeout and the request does not complete within that time, the timeout event fires:

const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://slow-api.example.com/data');
xhr.timeout = 5000; // 5 seconds

xhr.ontimeout = function() {
console.error('Request timed out after 5 seconds');
};

xhr.onload = function() {
console.log('Completed in time:', xhr.response);
};

xhr.send();

abort: Request Cancelled

Fires when xhr.abort() is called:

const xhr = new XMLHttpRequest();
xhr.open('GET', '/api/data');

xhr.onabort = function() {
console.log('Request was aborted');
};

xhr.send();

// Cancel the request
setTimeout(() => {
xhr.abort();
}, 1000);

progress: Download Progress

Fires periodically while the response body is being downloaded. The event object contains progress information:

const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://example.com/large-file.json');
xhr.responseType = 'json';

xhr.onprogress = function(event) {
if (event.lengthComputable) {
const percent = (event.loaded / event.total) * 100;
console.log(`Downloaded: ${percent.toFixed(1)}%`);
console.log(`${event.loaded} of ${event.total} bytes`);
} else {
console.log(`Downloaded: ${event.loaded} bytes`);
}
};

xhr.onload = function() {
console.log('Download complete');
};

xhr.send();

The progress event object properties:

PropertyTypeDescription
event.loadednumberBytes received so far
event.totalnumberTotal bytes expected (from Content-Length)
event.lengthComputablebooleanWhether total is known

loadstart and loadend

loadstart fires when the request begins. loadend fires when the request finishes, regardless of success or failure:

xhr.onloadstart = function() {
showSpinner();
console.log('Request started');
};

xhr.onloadend = function() {
hideSpinner();
console.log('Request finished (success or failure)');
};

loadend is useful for cleanup logic that should run no matter what happened (similar to finally in a try/catch).

readystatechange: The Old Way

Before the load, error, and progress events were standardized, the only way to monitor an XHR request was through readystatechange. This event fires every time the readyState property changes:

const xhr = new XMLHttpRequest();

xhr.onreadystatechange = function() {
console.log('readyState:', xhr.readyState);

switch (xhr.readyState) {
case 0: // UNSENT
console.log('Object created, open() not called');
break;
case 1: // OPENED
console.log('open() has been called');
break;
case 2: // HEADERS_RECEIVED
console.log('send() called, headers received');
console.log('Status:', xhr.status);
break;
case 3: // LOADING
console.log('Downloading response body...');
break;
case 4: // DONE
console.log('Request complete');
if (xhr.status === 200) {
console.log('Response:', xhr.responseText);
}
break;
}
};

xhr.open('GET', 'https://jsonplaceholder.typicode.com/users/1');
xhr.send();

The ready states:

ValueConstantMeaning
0UNSENTObject created, open() not called
1OPENEDopen() called
2HEADERS_RECEIVEDsend() called, headers and status available
3LOADINGResponse body downloading, responseText has partial data
4DONERequest complete

You will see readystatechange in older code frequently:

// Classic XHR pattern (pre-2014)
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function() {
if (xhr.readyState === 4) {
if (xhr.status === 200) {
var data = JSON.parse(xhr.responseText);
callback(null, data);
} else {
callback(new Error('Request failed: ' + xhr.status));
}
}
};
xhr.open('GET', '/api/data');
xhr.send();
tip

For new code (even when using XHR), prefer the load, error, timeout, and progress events over readystatechange. They are cleaner, more specific, and easier to reason about.

Using addEventListener vs. Event Properties

Both approaches work. addEventListener allows multiple handlers and more control:

// Event properties (one handler per event)
xhr.onload = function() { /* ... */ };
xhr.onerror = function() { /* ... */ };

// addEventListener (multiple handlers, removal possible)
xhr.addEventListener('load', handleSuccess);
xhr.addEventListener('load', logResponse); // Second handler for same event
xhr.addEventListener('error', handleError);

// Can remove specific handlers
xhr.removeEventListener('load', logResponse);

Complete Example with All Events

function makeRequest(method, url, data = null) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open(method, url);
xhr.responseType = 'json';

if (data && typeof data === 'object' && !(data instanceof FormData)) {
xhr.setRequestHeader('Content-Type', 'application/json');
data = JSON.stringify(data);
}

xhr.onloadstart = function() {
console.log('Request starting...');
};

xhr.onprogress = function(event) {
if (event.lengthComputable) {
console.log(`Progress: ${((event.loaded / event.total) * 100).toFixed(0)}%`);
}
};

xhr.onload = function() {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(xhr.response);
} else {
reject(new Error(`HTTP ${xhr.status}: ${xhr.statusText}`));
}
};

xhr.onerror = function() {
reject(new Error('Network error'));
};

xhr.ontimeout = function() {
reject(new Error('Request timed out'));
};

xhr.onloadend = function() {
console.log('Request finished');
};

xhr.timeout = 10000;
xhr.send(data);
});
}

// Usage with async/await
try {
const users = await makeRequest('GET', 'https://jsonplaceholder.typicode.com/users');
console.log(users);

const newPost = await makeRequest('POST', 'https://jsonplaceholder.typicode.com/posts', {
title: 'Hello',
body: 'World',
userId: 1
});
console.log('Created:', newPost);
} catch (error) {
console.error(error.message);
}

Upload Progress: xhr.upload.onprogress

This is the feature that keeps XHR relevant. The xhr.upload property is an XMLHttpRequestUpload object that fires progress events while the request body is being sent to the server.

How Upload Progress Works

const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/upload');

// UPLOAD progress (data going TO the server)
xhr.upload.onprogress = function(event) {
if (event.lengthComputable) {
const percent = (event.loaded / event.total) * 100;
console.log(`Upload: ${percent.toFixed(1)}%`);
}
};

xhr.upload.onloadstart = function() {
console.log('Upload started');
};

xhr.upload.onload = function() {
console.log('Upload complete (waiting for server response)');
};

xhr.upload.onerror = function() {
console.log('Upload failed');
};

// DOWNLOAD progress (response coming FROM the server)
xhr.onprogress = function(event) {
console.log(`Server response downloading: ${event.loaded} bytes`);
};

xhr.onload = function() {
console.log('Server responded:', xhr.status);
};

xhr.send(largeFile);

The key distinction:

  • xhr.upload.onprogress tracks data going to the server (upload)
  • xhr.onprogress tracks data coming from the server (download)

Upload Events

The xhr.upload object supports the same events as the XHR object itself:

EventWhen It Fires
xhr.upload.onloadstartUpload begins
xhr.upload.onprogressPeriodically during upload
xhr.upload.onloadUpload completes successfully
xhr.upload.onerrorUpload fails (network error)
xhr.upload.onabortUpload is cancelled
xhr.upload.ontimeoutUpload times out
xhr.upload.onloadendUpload finishes (success or failure)

Practical File Upload with Progress Bar

<input type="file" id="fileInput" multiple>
<div id="uploadArea">
<div id="progressContainer" style="display: none;">
<div id="progressBar" style="
width: 0%;
height: 24px;
background: linear-gradient(90deg, #3498db, #2ecc71);
border-radius: 12px;
transition: width 0.1s ease;
"></div>
<span id="progressText">0%</span>
</div>
<button id="uploadBtn" disabled>Upload</button>
<button id="cancelBtn" style="display: none;">Cancel</button>
<p id="statusText"></p>
</div>

<script>
const fileInput = document.getElementById('fileInput');
const progressContainer = document.getElementById('progressContainer');
const progressBar = document.getElementById('progressBar');
const progressText = document.getElementById('progressText');
const uploadBtn = document.getElementById('uploadBtn');
const cancelBtn = document.getElementById('cancelBtn');
const statusText = document.getElementById('statusText');

let currentXHR = null;

fileInput.addEventListener('change', () => {
uploadBtn.disabled = fileInput.files.length === 0;
});

uploadBtn.addEventListener('click', uploadFiles);
cancelBtn.addEventListener('click', () => {
if (currentXHR) {
currentXHR.abort();
statusText.textContent = 'Upload cancelled';
}
});

function uploadFiles() {
const files = fileInput.files;
if (files.length === 0) return;

const formData = new FormData();
for (const file of files) {
formData.append('files', file);
}

const xhr = new XMLHttpRequest();
currentXHR = xhr;

xhr.open('POST', '/api/upload');

// Show progress UI
progressContainer.style.display = 'block';
cancelBtn.style.display = 'inline-block';
uploadBtn.disabled = true;

// Track upload progress
xhr.upload.onprogress = function(event) {
if (event.lengthComputable) {
const percent = (event.loaded / event.total) * 100;
progressBar.style.width = percent + '%';
progressText.textContent = `${percent.toFixed(1)}% (${formatBytes(event.loaded)} / ${formatBytes(event.total)})`;
}
};

xhr.upload.onload = function() {
statusText.textContent = 'Upload complete. Processing on server...';
};

xhr.upload.onerror = function() {
statusText.textContent = 'Upload failed - network error';
resetUI();
};

xhr.upload.onabort = function() {
progressBar.style.width = '0%';
progressText.textContent = 'Cancelled';
resetUI();
};

// Handle server response
xhr.onload = function() {
if (xhr.status >= 200 && xhr.status < 300) {
statusText.textContent = 'Files uploaded successfully!';
statusText.style.color = 'green';
} else {
statusText.textContent = `Server error: ${xhr.status} ${xhr.statusText}`;
statusText.style.color = 'red';
}
resetUI();
};

xhr.onerror = function() {
statusText.textContent = 'Network error occurred';
statusText.style.color = 'red';
resetUI();
};

xhr.send(formData);
}

function resetUI() {
cancelBtn.style.display = 'none';
uploadBtn.disabled = false;
currentXHR = null;
}

function formatBytes(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / 1048576).toFixed(1) + ' MB';
}
</script>

Multiple File Upload with Individual Progress

For uploading multiple files and tracking each one separately:

function uploadFileWithProgress(file, url) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', url);

const formData = new FormData();
formData.append('file', file);

xhr.upload.onprogress = function(event) {
if (event.lengthComputable) {
const percent = (event.loaded / event.total) * 100;
updateFileProgress(file.name, percent);
}
};

xhr.onload = function() {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(JSON.parse(xhr.responseText));
} else {
reject(new Error(`Upload failed: ${xhr.status}`));
}
};

xhr.onerror = function() {
reject(new Error('Network error'));
};

xhr.send(formData);
});
}

function updateFileProgress(filename, percent) {
const el = document.getElementById(`progress-${filename}`);
if (el) {
el.style.width = `${percent}%`;
el.textContent = `${filename}: ${percent.toFixed(0)}%`;
}
}

// Upload files sequentially
async function uploadAllFiles(files) {
for (const file of files) {
// Create progress bar for this file
createProgressElement(file.name);

try {
const result = await uploadFileWithProgress(file, '/api/upload');
console.log(`${file.name} uploaded:`, result);
} catch (error) {
console.error(`${file.name} failed:`, error.message);
}
}
}

// Or upload in parallel
async function uploadAllFilesParallel(files) {
const promises = Array.from(files).map(file => {
createProgressElement(file.name);
return uploadFileWithProgress(file, '/api/upload');
});

const results = await Promise.allSettled(promises);
results.forEach((result, i) => {
if (result.status === 'fulfilled') {
console.log(`${files[i].name}: success`);
} else {
console.log(`${files[i].name}: failed - ${result.reason.message}`);
}
});
}

Aborting XHR Requests

XHR has a built-in abort() method:

const xhr = new XMLHttpRequest();
xhr.open('GET', '/api/data');

xhr.onload = function() {
console.log('Completed');
};

xhr.onabort = function() {
console.log('Aborted');
};

xhr.send();

// Cancel after 2 seconds
setTimeout(() => xhr.abort(), 2000);

Search-as-you-type with XHR Abort

The same pattern as with fetch() + AbortController, but simpler with XHR:

let currentXHR = null;

searchInput.addEventListener('input', function(event) {
const query = event.target.value.trim();

// Cancel previous request
if (currentXHR) {
currentXHR.abort();
}

if (!query) {
results.innerHTML = '';
return;
}

const xhr = new XMLHttpRequest();
currentXHR = xhr;

xhr.open('GET', `/api/search?q=${encodeURIComponent(query)}`);
xhr.responseType = 'json';

xhr.onload = function() {
if (xhr.status === 200) {
renderResults(xhr.response);
}
};

xhr.onabort = function() {
// Previous search cancelled - expected, do nothing
};

xhr.send();
});

XHR vs. Fetch Comparison

Understanding the differences helps you choose the right tool and read legacy code with confidence.

Feature Comparison

FeatureXHRFetch
API StyleEvent-based callbacksPromise-based
SyntaxVerbose, multi-stepClean, concise
Download Progressxhr.onprogressresponse.body ReadableStream
Upload Progressxhr.upload.onprogressNot supported natively
Cancellationxhr.abort()AbortController
Timeoutxhr.timeout propertyAbortSignal.timeout()
StreamingNoYes (ReadableStream)
Request ObjectNo (config on XHR instance)Request object
Response ObjectProperties on XHR instanceResponse object
JSON ParsingresponseType = 'json'response.json()
FormDataxhr.send(formData)body: formData
CORSSupportedSupported
Cookiesxhr.withCredentials = truecredentials: 'include'
Cache ControlVia headers onlycache option
Service WorkersCannot interceptInterceptable
HTTP/2 PushNoYes
Browser SupportAll browsers (IE6+)All modern browsers

Side-by-Side Code Comparison

Simple GET request:

// XHR
const xhr = new XMLHttpRequest();
xhr.open('GET', '/api/users');
xhr.responseType = 'json';
xhr.onload = function() {
if (xhr.status === 200) {
console.log(xhr.response);
} else {
console.error('Error:', xhr.status);
}
};
xhr.onerror = function() {
console.error('Network error');
};
xhr.send();

// Fetch
try {
const response = await fetch('/api/users');
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
console.log(data);
} catch (error) {
console.error(error.message);
}

POST with JSON:

// XHR
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/users');
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.responseType = 'json';
xhr.onload = function() {
if (xhr.status === 201) {
console.log('Created:', xhr.response);
}
};
xhr.send(JSON.stringify({ name: 'Alice', email: 'alice@example.com' }));

// Fetch
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Alice', email: 'alice@example.com' })
});
const created = await response.json();
console.log('Created:', created);

Upload with progress:

// XHR - clean, built-in support
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/upload');
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
console.log(`${((e.loaded / e.total) * 100).toFixed(0)}%`);
}
};
xhr.onload = () => console.log('Done');
xhr.send(formData);

// Fetch - no upload progress available
// You would need XHR or a library like Axios for this
const response = await fetch('/api/upload', {
method: 'POST',
body: formData
// No way to track upload progress!
});

Timeout:

// XHR
xhr.timeout = 5000;
xhr.ontimeout = () => console.log('Timed out');

// Fetch
const response = await fetch(url, {
signal: AbortSignal.timeout(5000)
});

Credentials (cookies):

// XHR
xhr.withCredentials = true;

// Fetch
fetch(url, { credentials: 'include' });

When to Use XHR

Despite fetch() being the modern standard, XHR is still the right choice in specific situations:

// 1. Upload progress is required
function uploadWithProgress(file, onProgress) {
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/upload');

xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
onProgress(event.loaded / event.total);
}
};

return new Promise((resolve, reject) => {
xhr.onload = () => xhr.status < 300 ? resolve(xhr.response) : reject(new Error(`${xhr.status}`));
xhr.onerror = () => reject(new Error('Network error'));
xhr.send(file);
});
}

// 2. Working with legacy code that already uses XHR

// 3. You need to support very old browsers (rare today)

When to Use Fetch

For everything else, prefer fetch():

// Cleaner syntax
const data = await fetch('/api/data').then(r => r.json());

// Better streaming support
const reader = response.body.getReader();

// Works with Service Workers
self.addEventListener('fetch', event => {
event.respondWith(caches.match(event.request));
});

// Cleaner abort mechanism
const controller = new AbortController();
fetch(url, { signal: controller.signal });

// Cache control
fetch(url, { cache: 'no-store' });

// Priority hints
fetch(url, { priority: 'high' });

Wrapping XHR in Promises

If you need to use XHR (for upload progress) but want the clean syntax of Promises and async/await, wrap it:

function xhrRequest({ method = 'GET', url, data, headers = {}, responseType = 'json',
timeout = 0, onUploadProgress, onDownloadProgress }) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open(method, url);
xhr.responseType = responseType;

if (timeout > 0) xhr.timeout = timeout;

// Set headers
for (const [key, value] of Object.entries(headers)) {
xhr.setRequestHeader(key, value);
}

// Upload progress
if (onUploadProgress) {
xhr.upload.onprogress = function(event) {
if (event.lengthComputable) {
onUploadProgress({
loaded: event.loaded,
total: event.total,
progress: event.loaded / event.total
});
}
};
}

// Download progress
if (onDownloadProgress) {
xhr.onprogress = function(event) {
if (event.lengthComputable) {
onDownloadProgress({
loaded: event.loaded,
total: event.total,
progress: event.loaded / event.total
});
}
};
}

xhr.onload = function() {
if (xhr.status >= 200 && xhr.status < 300) {
resolve({
status: xhr.status,
statusText: xhr.statusText,
data: xhr.response,
headers: parseHeaders(xhr.getAllResponseHeaders())
});
} else {
reject(Object.assign(
new Error(`HTTP ${xhr.status}: ${xhr.statusText}`),
{ status: xhr.status, data: xhr.response }
));
}
};

xhr.onerror = () => reject(new Error('Network error'));
xhr.ontimeout = () => reject(new Error(`Timeout after ${timeout}ms`));
xhr.onabort = () => reject(new Error('Request aborted'));

// Prepare body
let body = data;
if (data && typeof data === 'object' && !(data instanceof FormData) && !(data instanceof Blob)) {
if (!headers['Content-Type']) {
xhr.setRequestHeader('Content-Type', 'application/json');
}
body = JSON.stringify(data);
}

xhr.send(body || null);
});
}

function parseHeaders(headerString) {
const headers = {};
if (!headerString) return headers;
headerString.split('\r\n').forEach(line => {
if (!line) return;
const i = line.indexOf(':');
headers[line.substring(0, i).trim().toLowerCase()] = line.substring(i + 1).trim();
});
return headers;
}

Usage:

// Simple GET
const { data: users } = await xhrRequest({
url: '/api/users'
});

// POST with JSON
const { data: newUser } = await xhrRequest({
method: 'POST',
url: '/api/users',
data: { name: 'Alice', email: 'alice@example.com' }
});

// File upload with progress
const { data: result } = await xhrRequest({
method: 'POST',
url: '/api/upload',
data: formData,
onUploadProgress: ({ progress }) => {
progressBar.style.width = `${(progress * 100).toFixed(0)}%`;
}
});

Summary

XMLHttpRequest is the original browser API for making HTTP requests from JavaScript. While fetch() has replaced it as the modern standard, XHR remains important for two reasons: it powers vast amounts of legacy code, and it provides upload progress tracking that fetch() does not offer.

Making a request with XHR follows three steps: create with new XMLHttpRequest(), configure with open() and optional setRequestHeader() calls, and dispatch with send(). The responseType property controls how the response is parsed: 'json' for automatic JSON parsing, 'text' for strings, 'blob' for binary files, 'arraybuffer' for raw bytes, and 'document' for XML/HTML.

XHR uses an event-based model. The load event fires on completion (check status for HTTP errors), error fires on network failure, timeout fires when the time limit is exceeded, and progress fires periodically during download. The xhr.upload object provides the same events for tracking upload progress, with xhr.upload.onprogress being the standout feature that fetch() cannot replicate.

For new code, use fetch() whenever possible. It offers cleaner syntax with Promises, better streaming support, integration with Service Workers, and more configuration options. Use XHR when you need upload progress bars, or wrap XHR in Promises to get the best of both worlds: XHR's upload progress with async/await syntax.