fetch() and XMLHttpRequest
XMLHttpRequest for context, then the modern fetch() API: GET/POST, headers, bodies, and async/await.
XMLHttpRequest — the original way
Before fetch() existed, every AJAX request went through XMLHttpRequest (often shortened to XHR). It's rarely written by hand in new code today, but you'll still see it in older codebases and it's worth recognizing:
const xhr = new XMLHttpRequest();
xhr.open('GET', '/api/users');
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
const data = JSON.parse(xhr.responseText);
console.log(data);
} else {
console.error('Request failed with status', xhr.status);
}
};
xhr.onerror = () => console.error('Network error');
xhr.send();
Compare that to the equivalent fetch() call later in this page — XHR requires manually creating an object, wiring up event-based callbacks (onload, onerror), manually parsing the response text as JSON, and manually checking the status code range. It works, but it's verbose and easy to get subtly wrong (forgetting onerror, for instance, silently swallows network failures).
Enter fetch()
The fetch() API, available in every modern browser and in Node.js since version 18, is the modern replacement for XMLHttpRequest. It's promise-based, which means it composes naturally with .then() chains and async/await, instead of requiring callback-style event handlers.
fetch('/api/users')
.then(response => response.json())
.then(data => console.log(data))
.catch(error => console.error('Request failed:', error));
The same call written with async/await, which is the style you'll see most often in modern codebases:
async function loadUsers() {
try {
const response = await fetch('/api/users');
const data = await response.json();
console.log(data);
} catch (error) {
console.error('Request failed:', error);
}
}
fetch() returns a Promise that resolves to a Response object as soon as the server sends response headers — the body is read separately (and also asynchronously) via methods like .json(), .text(), or .blob(). This two-step design (headers first, body second) is why .json() itself returns another promise that must also be awaited.
GET requests
A plain fetch(url) call defaults to a GET request — no extra configuration needed:
const response = await fetch('/api/products?category=electronics&limit=20');
const products = await response.json();
Query parameters are built the same way as any URL. For dynamic values, URLSearchParams avoids manual string concatenation and handles encoding correctly:
const params = new URLSearchParams({ category: 'electronics', limit: 20 });
const response = await fetch(`/api/products?${params}`);
POST requests with a JSON body
For anything beyond a simple GET, pass a second argument — an options object — to fetch():
const response = await fetch('/api/products', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: 'Wireless Mouse',
price: 24.99,
}),
});
const created = await response.json();
Two details trip up almost everyone the first time:
bodymust be a string (orFormData,Blob, etc.) — you must callJSON.stringify()yourself.fetch()does not serialize plain objects for you.- The
Content-Type: application/jsonheader must be set explicitly. Without it, many servers won't know to parse the body as JSON, even though the body content itself is valid JSON text.
Setting headers
Headers are passed as a plain object (or a Headers instance) under the headers key — commonly used for authentication tokens and content negotiation:
const response = await fetch('/api/profile', {
headers: {
'Authorization': `Bearer ${accessToken}`,
'Accept': 'application/json',
},
});
.then() chains vs async/await
Both styles do exactly the same thing under the hood — async/await is syntax sugar over promises, not a different mechanism. The difference is readability, especially once you have more than one dependent request:
// .then() chain — each step nests or chains
fetch('/api/user/42')
.then(response => response.json())
.then(user => fetch(`/api/orders?userId=${user.id}`))
.then(response => response.json())
.then(orders => console.log(orders))
.catch(error => console.error(error));
// async/await — reads top to bottom, like synchronous code
async function loadUserOrders() {
try {
const userResponse = await fetch('/api/user/42');
const user = await userResponse.json();
const ordersResponse = await fetch(`/api/orders?userId=${user.id}`);
const orders = await ordersResponse.json();
console.log(orders);
} catch (error) {
console.error(error);
}
}
Most teams prefer async/await for anything with more than one sequential step — it avoids the growing indentation and makes error handling a single try/catch block instead of a chain of .catch() calls.
Aborting a request
AbortController lets you cancel an in-flight fetch() — useful for search-as-you-type inputs where a new keystroke should cancel the previous, now-stale request:
let controller = null;
async function search(query) {
if (controller) controller.abort(); // cancel the previous request
controller = new AbortController();
try {
const response = await fetch(`/api/search?q=${query}`, {
signal: controller.signal,
});
const results = await response.json();
renderResults(results);
} catch (error) {
if (error.name !== 'AbortError') {
console.error('Search failed:', error);
}
}
}
Common mistakes
- Forgetting that
fetch()needs a second.then()/awaitstep to read the body —responseitself is not the data,response.json()(a separate async step) is. - Sending a plain JavaScript object as
bodywithout callingJSON.stringify()on it first — the server receives the literal string"[object Object]". - Setting the request body but forgetting the
Content-Type: application/jsonheader.
Interview questions
Q: What's the main practical difference between XMLHttpRequest and fetch()?
fetch() is promise-based and composes naturally with async/await, while XMLHttpRequest is event/callback-based (onload, onerror) and requires more boilerplate — manually parsing response text, manually checking status codes. fetch() is the modern default; XHR mostly appears in legacy code or libraries that need very fine-grained progress events, which fetch() historically lacked.
Q: Why do you need to call JSON.stringify() before sending a request body?
Because the body option of fetch() must be a string (or another supported body type like FormData), not a plain JavaScript object. JSON.stringify() converts the object into its JSON text representation, which is what actually gets sent over the wire.