Fetch and XHR
Date: 2026-08-17
The two request APIs.
fetchreplacedXMLHttpRequestfor almost everything, and the differences that still matter are three: fetch doesn’t reject on HTTP errors, it doesn’t send cookies cross-origin by default, and it can’t report upload progress.
fetch and XMLHttpRequest (XHR) are the browser APIs for making HTTP requests from JavaScript without a page load; fetch is promise-based, XHR is event-based.
The smallest working form
const res = await fetch('/api/basket');
const data = await res.json();Complete. Two things it does that surprise people, both below.
The three real differences
1. fetch does not reject on 4xx or 5xx.
// ✗ a 500 lands here as a SUCCESS. data is whatever the error page was
const res = await fetch('/api/basket');
const data = await res.json(); // throws a confusing JSON parse error
// ✓ check res.ok — it's true only for 200–299
const res = await fetch('/api/basket');
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
const data = await res.json();A rejected fetch means the request never completed — network failure, DNS failure, CORS block, abort. A server responding “no” is, to fetch, a successful round trip. This is the single most common fetch bug and it produces error messages that point at the wrong thing.
2. Credentials. fetch sends cookies same-origin by default and not cross-origin, where XHR’s behaviour differed.
fetch(url, { credentials: 'same-origin' }) // default
fetch(url, { credentials: 'include' }) // cross-origin cookies —
// needs matching CORS headers
fetch(url, { credentials: 'omit' }) // nevercredentials: 'include' requires the server to send Access-Control-Allow-Credentials: true and a specific origin — a wildcard * is rejected with credentials — CORS.
3. Upload progress. XHR can report bytes sent; fetch cannot. This is the one genuine reason to still reach for XHR — a file upload with a progress bar.
const xhr = new XMLHttpRequest();
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) setProgress(e.loaded / e.total);
};
xhr.open('POST', '/upload');
xhr.send(formData);Download progress is readable with fetch, via the response body stream — Streaming Responses.
Cancellation
AbortController is the mechanism, and it works for both APIs plus most other async browser work.
const ac = new AbortController();
const res = await fetch('/api/search?q=' + q, { signal: ac.signal });
// elsewhere — a new keystroke, or the component unmounting
ac.abort(); // the pending fetch rejects with an AbortError// the practical pattern: cancel the previous search on every keystroke
let current;
async function search(q) {
current?.abort();
current = new AbortController();
try {
const res = await fetch(`/api/search?q=${q}`, { signal: current.signal });
render(await res.json());
} catch (e) {
if (e.name !== 'AbortError') throw e; // ignore our own cancellation
}
}Aborting is not just tidiness. Without it, responses arrive out of order and a slow earlier request can overwrite a fast later one — a race that shows up as search results flickering back to a previous query — Race Conditions.
There’s no built-in timeout. AbortSignal.timeout(ms) supplies one; otherwise a fetch waits as long as the browser allows, which on a bad connection is a long time — Graceful Degradation.
The request options worth knowing
await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sku, quantity }),
signal: AbortSignal.timeout(5000),
keepalive: true, // survives page unload — see below
cache: 'no-store', // bypass the HTTP cache entirely
priority: 'low', // deprioritise against page content
});keepalive is the one worth knowing for analytics. A normal fetch is cancelled when the page unloads, which is exactly when you want to send a final event. keepalive lets it complete — with a payload cap of around 64 KB across all in-flight keepalive requests.
navigator.sendBeacon() does the same job with a simpler contract and no way to read the response, which is usually what you want for telemetry — Event Batching and Delivery.
Where it interacts
- CORS — the cross-origin rules that decide whether a fetch is allowed at all, and why the error is always misread
- Streaming Responses — consuming a response body before it’s finished
- Error Handling Strategies — retries, backoff and the
res.okcheck belong in one shared wrapper rather than at each call site - Idempotency — anything retried needs to be safe to repeat, which is a server contract rather than a client one