Tags: web-dev concept

Fetch and XHR

Date: 2026-08-17


The two request APIs. fetch replaced XMLHttpRequest for 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' })          // never

credentials: '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.ok check 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