AV
HomeAboutProjectBlog

© 2026 Ave syah Shina. All rights reserved.

  1. Home
  2. Blog
  3. 17 — Working with APIs — XHR, Fetch, and async/await

17 — Working with APIs — XHR, Fetch, and async/await

August 13, 20265 min read
Download as Markdown

"Just use fetch" was my calling-APIs model, and it skipped the history that explains why codebases look the way they do. The idea that everything else hangs off: the browser ships two native HTTP mechanisms — XMLHttpRequest (XHR, the legacy callback API) and fetch (the modern promise-based API) — and async/await over fetch is the modern default, with XHR reserved for legacy code or the rare feature fetch doesn't cover. [1]

The framing that finally landed is the three-eras view. Each era kept the underlying HTTP the same and only improved the ergonomics of waiting for the response:

same HTTP underneath — only the waiting ergonomics improved XMLHttpRequest ~2006 · callbacks xhr.onload = () => { JSON.parse(xhr.response) } xhr.open("GET", url) xhr.send() verbose · legacy Fetch API ~2015 · promises fetch(url) .then(r => r.json()) .then(data => …) .catch(err => …) promise chains async/await over fetch ~2017 · modern default const r = await fetch(url) const data = await r.json() try { … } catch (e) { … } linear · readable

XMLHttpRequest: the legacy API

XHR is the original browser API for HTTP requests, predating fetch by a decade [2]. Despite the "XML" in the name, it works with any data format. The shape is verbose and callback-based:

const xhr = new XMLHttpRequest();
xhr.open("GET", "/api/users");
xhr.onload = () => {
if (xhr.status === 200) {
const data = JSON.parse(xhr.response);
// use data
}
};
xhr.onerror = () => console.error("request failed");
xhr.send();

I almost never write XHR in new code. The reasons it still exists: a vast amount of legacy code uses it, and it has a handful of features fetch historically lacked (progress events for uploads, easy abort before AbortController). For new work, fetch covers everything I need.

Fetch: the modern promise API

fetch() is the modern standard — promise-based, cleaner, and the right default [3]. The key thing I had to internalize: fetch only rejects on a network error, not on HTTP error statuses. A 404 or 500 is still a fulfilled promise; I have to check response.ok myself:

fetch("/api/users")
.then(response => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json(); // parse the body — also async
})
.then(data => console.log(data))
.catch(err => console.error(err));

That response.ok check (true for statuses 200–299) is the part people forget — without it, a 500 looks like success. The body is a stream, so reading it (.json(), .text(), .blob()) returns another promise that resolves when parsing finishes.

async/await: the linear version

Wrapping fetch in async/await is the modern default — same machinery, reads top to bottom, and try/catch works naturally [4][5]:

async function loadUsers() {
try {
const response = await fetch("/api/users");
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
return data;
} catch (err) {
console.error("Failed to load users:", err);
return [];
}
}

Two awaits — one for the response headers to arrive, one for the body to parse. Both are async because both involve waiting. The try/catch now catches both network errors and my explicit throw, which is the cleanup I wanted.

The options I actually pass

fetch's second argument configures the request [3]. The common ones:

await fetch("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Ave" }),
credentials: "include", // send cookies
signal: controller.signal, // for cancellation via AbortController
});

AbortController is the modern way to cancel an in-flight request — create a controller, pass its signal, and call controller.abort() when I want to stop (e.g., a React cleanup function when a component unmounts). Before AbortController, you simply couldn't cancel a fetch.

How I use this

Every new API call is async/await over fetch with a response.ok check. I wrap the shared logic (URL building, auth headers, JSON parsing, error normalization) in a small helper so each call site stays focused on what's specific. For complex needs — retries, caching, deduplication, SSR — I reach for a data-fetching library (SWR, React Query, Apollo) that sits on top of fetch and handles those concerns; raw fetch is still doing the actual HTTP underneath. XHR I touch only when reading or maintaining older code. The response.ok discipline is the one habit that prevents the most bugs — without it, every 4xx/5xx silently looks like success, and that's a class of failure that's very hard to trace later.

References

[1] Mozilla, "Fetching data from the server," MDN Web Docs, 2024. [Online]. Available: https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Client-side_web_APIs/Fetching_data

[2] Mozilla, "XMLHttpRequest," MDN Web Docs, 2024. [Online]. Available: https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest

[3] Mozilla, "Using the Fetch API," MDN Web Docs, 2024. [Online]. Available: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch

[4] I. Kantor, "Fetch," The Modern JavaScript Tutorial, 2024. [Online]. Available: https://javascript.info/fetch

[5] I. Kantor, "Async/await," The Modern JavaScript Tutorial, 2024. [Online]. Available: https://javascript.info/async-await

Knowledge check · Question 1 of 5

fetch rejects the promise (goes to .catch) when…

Comments

Leave a Comment

You must be signed in to comment

0 Comments

No comments yet. Be the first to comment!