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);
| Parameter | Required | Description |
|---|---|---|
method | Yes | HTTP method: 'GET', 'POST', 'PUT', 'DELETE', etc. |
url | Yes | The URL to request |
async | No | true (default) for async, false for synchronous (deprecated) |
username | No | Username for HTTP authentication |
password | No | Password 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);
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 beforesend() - 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:
responseType | xhr.response Type | Use Case |
|---|---|---|
'' (default) | string | Same as 'text' |
'text' | string | Plain text, HTML, CSV |
'json' | object (parsed JSON) | API responses |
'blob' | Blob | Images, files, downloads |
'arraybuffer' | ArrayBuffer | Binary 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();
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:
| Property | Type | Description |
|---|---|---|
event.loaded | number | Bytes received so far |
event.total | number | Total bytes expected (from Content-Length) |
event.lengthComputable | boolean | Whether 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:
| Value | Constant | Meaning |
|---|---|---|
| 0 | UNSENT | Object created, open() not called |
| 1 | OPENED | open() called |
| 2 | HEADERS_RECEIVED | send() called, headers and status available |
| 3 | LOADING | Response body downloading, responseText has partial data |
| 4 | DONE | Request 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();
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.onprogresstracks data going to the server (upload)xhr.onprogresstracks data coming from the server (download)
Upload Events
The xhr.upload object supports the same events as the XHR object itself:
| Event | When It Fires |
|---|---|
xhr.upload.onloadstart | Upload begins |
xhr.upload.onprogress | Periodically during upload |
xhr.upload.onload | Upload completes successfully |
xhr.upload.onerror | Upload fails (network error) |
xhr.upload.onabort | Upload is cancelled |
xhr.upload.ontimeout | Upload times out |
xhr.upload.onloadend | Upload 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
| Feature | XHR | Fetch |
|---|---|---|
| API Style | Event-based callbacks | Promise-based |
| Syntax | Verbose, multi-step | Clean, concise |
| Download Progress | xhr.onprogress | response.body ReadableStream |
| Upload Progress | xhr.upload.onprogress | Not supported natively |
| Cancellation | xhr.abort() | AbortController |
| Timeout | xhr.timeout property | AbortSignal.timeout() |
| Streaming | No | Yes (ReadableStream) |
| Request Object | No (config on XHR instance) | Request object |
| Response Object | Properties on XHR instance | Response object |
| JSON Parsing | responseType = 'json' | response.json() |
| FormData | xhr.send(formData) | body: formData |
| CORS | Supported | Supported |
| Cookies | xhr.withCredentials = true | credentials: 'include' |
| Cache Control | Via headers only | cache option |
| Service Workers | Cannot intercept | Interceptable |
| HTTP/2 Push | No | Yes |
| Browser Support | All 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.