Fetching Data: fetch vs XMLHttpRequest
Making HTTP requests is fundamental to building modern web applications. Whether you are fetching data from an API, submitting a form, or loading resources from a CDN, you need a way to communicate with servers. For years, XMLHttpRequest was the only option in browsers. Then the Fetch API arrived, offering a cleaner and more powerful alternative.
This tutorial compares both approaches, shows you how each works, and helps you decide which to use in your projects.
What is XMLHttpRequest?
XMLHttpRequest (XHR) is a browser API that has existed since the late 1990s. Despite its name, it works with any data format, not just XML. It was the backbone of early AJAX applications and enabled the dynamic web experiences we have today.
Making a request with XMLHttpRequest
const xhr = new XMLHttpRequest();
xhr.open("GET", "https://jsonplaceholder.typicode.com/posts/1");
xhr.responseType = "json";
xhr.onload = function() {
if (xhr.status >= 200 && xhr.status < 300) {
console.log("Success:", xhr.response);
} else {
console.error("Error:", xhr.statusText);
}
};
xhr.onerror = function() {
console.error("Network error occurred");
};
xhr.send();
Notice the pattern: you create an XHR object, configure it with open(), attach event listeners for onload and onerror, then send the request with send(). The responseType property tells XHR how to parse the response body. For POST requests and other methods that send data, you also need to set request headers before calling send():
Handling different HTTP methods
// POST request with XMLHttpRequest
const xhrPost = new XMLHttpRequest();
xhrPost.open("POST", "https://jsonplaceholder.typicode.com/posts");
xhrPost.setRequestHeader("Content-Type", "application/json");
xhrPost.responseType = "json";
xhrPost.onload = function() {
console.log("Created:", xhrPost.response);
};
const data = JSON.stringify({
title: "My Post",
body: "Content here",
userId: 1
});
xhrPost.send(data);
What is the Fetch API?
The Fetch API is a modern interface for making HTTP requests. It uses Promises, making it easier to work with asynchronous code compared to XHR’s event-based approach. The basic pattern is: call fetch() with a URL, get back a Response object wrapped in a Promise, and extract the body with a method like .json().
Making a request with fetch
fetch("https://jsonplaceholder.typicode.com/posts/1")
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
})
.then(data => {
console.log("Success:", data);
})
.catch(error => {
console.error("Error:", error);
});
The response is a Promise that resolves to a Response object. You need to call a method like .json(), .text(), or .blob() to extract the body. Unlike XHR, fetch also lets you configure the request method, headers, and body through a single options object passed as the second argument:
Handling different HTTP methods
// POST request with fetch
fetch("https://jsonplaceholder.typicode.com/posts", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
title: "My Post",
body: "Content here",
userId: 1
})
})
.then(response => response.json())
.then(data => console.log("Created:", data))
.catch(error => console.error("Error:", error));
Using async/await with fetch
The Fetch API works naturally with async/await syntax, making asynchronous code read like synchronous code. Instead of chaining .then() calls, you can await the fetch and await the response parsing. Error handling moves into a familiar try/catch block:
async function fetchPost(id) {
try {
const response = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
console.log("Success:", data);
return data;
} catch (error) {
console.error("Error:", error);
}
}
fetchPost(1);
Key Differences
| Feature | XMLHttpRequest | Fetch API |
|---|---|---|
| Syntax | Event-based | Promise-based |
| Request configuration | open(), setRequestHeader() | Options object |
| Response handling | response property | .json(), .text() methods |
| Progress tracking | onprogress event | Not built-in |
| CORS handling | Manual | Automatic |
| Aborting requests | abort() method | AbortController |
When to use fetch
Use the Fetch API for most new projects:
- Cleaner, more readable code with Promises and async/await
- Better error handling with HTTP status codes
- Built-in support for Service Workers
- More flexible request configuration
// Fetch with timeout using AbortController
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
fetch("https://jsonplaceholder.typicode.com/posts/1", {
signal: controller.signal
})
.then(response => response.json())
.then(data => {
clearTimeout(timeoutId);
console.log(data);
})
.catch(error => {
clearTimeout(timeoutId);
if (error.name === "AbortError") {
console.log("Request timed out");
} else {
console.error("Error:", error);
}
});
When to use XMLHttpRequest
XMLHttpRequest is still useful in specific scenarios where you need capabilities that fetch does not provide out of the box. Progress tracking for uploads and downloads is the most common reason to reach for XHR today, since fetch’s streaming API requires a more complex setup:
- Supporting very old browsers (Internet Explorer 10 and below)
- Needing fine-grained progress events for file uploads
- Working with legacy codebases that already use XHR
// Progress tracking with XMLHttpRequest
const xhr = new XMLHttpRequest();
xhr.open("GET", "https://example.com/large-file");
xhr.onprogress = function(event) {
if (event.lengthComputable) {
const percentComplete = (event.loaded / event.total) * 100;
console.log(`Progress: ${percentComplete}%`);
}
};
xhr.onload = function() {
console.log("Download complete");
};
xhr.send();
Best Practices
Following a few consistent patterns prevents the most common fetch and XHR mistakes. The most important rule applies to both APIs: always check the HTTP status code before treating the response as successful.
-
Always check response.ok or status codes — Fetch does not reject on HTTP errors:
// Wrong: Fetch does not throw on 404 fetch("/api/user").then(user => console.log(user)); // Will fail // Correct: Check response.ok first fetch("/api/user") .then(response => { if (!response.ok) throw new Error("Request failed"); return response.json(); }) -
Use AbortController for cancellation to clean up pending requests when the user navigates away or when a newer request supersedes an older one:
const controller = new AbortController(); fetch(url, { signal: controller.signal }); // Later: controller.abort(); -
Set appropriate timeouts to prevent requests from hanging indefinitely
-
Handle both network and HTTP errors using try/catch with async/await. A single error handler that covers both failure modes keeps the error surface predictable:
async function safeFetch(url) { try { const response = await fetch(url); if (!response.ok) throw new Error(`HTTP ${response.status}`); return await response.json(); } catch (error) { console.error("Failed:", error.message); } }
Choosing the right request API
Fetch is the better default for new code because it fits the promise-based style most JavaScript already uses. It is easier to read, easier to compose with async functions, and easier to build into modern app flows. XMLHttpRequest still has a place when you need upload progress events or when you are keeping a legacy code path alive, but for most browser code the simpler shape of fetch is the better long-term choice. That is especially true when the request logic will be shared across several screens.
The decision usually comes down to the shape of the surrounding code. If the request is part of a modern async pipeline, fetch keeps the code compact. If the code is tied to older patterns or detailed progress tracking, XHR may be the more practical tool. Neither API replaces good error handling. The request still needs to deal with failed status codes, network loss, and unexpected response bodies. The transport is only one part of the feature.
Working With Responses
A response object is only useful once you decide what the body means. Sometimes the right answer is JSON, sometimes it is text, and sometimes it is binary data. Fetch makes that choice explicit, which is a good thing. It pushes you to think about the payload instead of assuming every server answer is the same. That clear separation also makes code review easier because the response handling tells you what kind of data the feature expects.
It is also worth keeping response parsing close to the request. If a feature always expects JSON, parse it in the same helper that performs the request so the rest of the app receives a ready-to-use object. That keeps repeated response.json() calls from spreading through the codebase. The same idea applies to XHR: decide on the response type up front and keep the handling path obvious.
Reliability And Cancellation
Network work should be cancelable when the user no longer needs it. That is true for searches, page transitions, and anything that may be superseded by newer input. AbortController gives fetch a clean escape hatch, and XHR has its own abort path. The important part is not the exact API, but the habit of stopping work that is no longer relevant. That keeps the UI more responsive and prevents old requests from racing against newer ones.
Timeouts matter for the same reason. A request that never finishes can leave the screen feeling stuck. A clear timeout and a clean error message make failures easier to understand and easier to recover from. When the request layer is honest about success, failure, and cancellation, the rest of the app can behave with more confidence.
Summary
The Fetch API is the modern standard for making HTTP requests in browsers. It provides a cleaner Promise-based interface that works naturally with async/await. Use it for all new code.
XMLHttpRequest remains relevant for legacy browser support and specific use cases like progress tracking. However, for most applications, Fetch is the better choice.
In the next tutorial, we’ll explore the Geolocation API for accessing the user’s location data.
Final request note
The cleanest request code is usually the code that makes the response shape obvious. If the feature expects JSON, parse JSON right away. If it expects text or binary data, say that directly. That small habit keeps the request layer easy to read and makes the rest of the app easier to change.
A small helper that owns the request and parsing steps also makes retries and cancellation easier to add later. That is often the difference between code that feels temporary and code that can stay in place for a long time.