---
title: "17 — Working with APIs — XHR, Fetch, and async/await"
uid: working-with-apis
tags: ["network", "roadmap:javascript", "fetch", "api", "http", "xhr", "async-await", "javascript"]
excerpt: "Two native HTTP mechanisms (XHR and fetch), three eras of ergonomics (callbacks, promise chains, async/await) — and async/await over fetch is the modern default."
date: 2026-08-13T03:28:04+0000
source: https://www.aveshina.my.id/en/blog/working-with-apis
---

"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:

```figure
<svg viewBox="0 0 740 260" xmlns="http://www.w3.org/2000/svg" class="my-6 w-full max-w-2xl" role="img" aria-label="Three eras of making HTTP requests in the browser, left to right. Era 1: XMLHttpRequest with callbacks (legacy). Era 2: Fetch API with promise .then chains. Era 3: async/await over fetch — reads linearly, the modern default. Same HTTP underneath; only the waiting ergonomics improved.">
  <g font-family="ui-sans-serif, system-ui, sans-serif" text-rendering="geometricPrecision">
    <text x="370" y="22" font-size="10" font-weight="700" fill="#475569" text-anchor="middle">same HTTP underneath — only the waiting ergonomics improved</text>

    <!-- Era 1: XHR -->
    <rect x="20" y="45" width="225" height="180" rx="10" fill="#fee2e2" stroke="#dc2626" stroke-width="1.5"/>
    <text x="132" y="68" font-size="12" font-weight="700" fill="#7f1d1d" text-anchor="middle">XMLHttpRequest</text>
    <text x="132" y="84" font-size="9" fill="#7f1d1d" text-anchor="middle">~2006 · callbacks</text>
    <g font-size="9" font-family="ui-monospace,monospace" fill="#7f1d1d">
      <text x="32" y="112">xhr.onload = () =&gt; {</text>
      <text x="40" y="128">JSON.parse(xhr.response)</text>
      <text x="32" y="144">}</text>
      <text x="32" y="162">xhr.open("GET", url)</text>
      <text x="32" y="178">xhr.send()</text>
    </g>
    <text x="132" y="208" font-size="9" font-style="italic" fill="#64748b" text-anchor="middle">verbose · legacy</text>

    <!-- Era 2: Fetch -->
    <rect x="260" y="45" width="225" height="180" rx="10" fill="#e0e7ff" stroke="#6366f1" stroke-width="1.5"/>
    <text x="372" y="68" font-size="12" font-weight="700" fill="#1e1b4b" text-anchor="middle">Fetch API</text>
    <text x="372" y="84" font-size="9" fill="#1e1b4b" text-anchor="middle">~2015 · promises</text>
    <g font-size="9" font-family="ui-monospace,monospace" fill="#1e1b4b">
      <text x="272" y="120">fetch(url)</text>
      <text x="280" y="136">.then(r =&gt; r.json())</text>
      <text x="280" y="152">.then(data =&gt; …)</text>
      <text x="280" y="168">.catch(err =&gt; …)</text>
    </g>
    <text x="372" y="208" font-size="9" font-style="italic" fill="#64748b" text-anchor="middle">promise chains</text>

    <!-- Era 3: async/await -->
    <rect x="500" y="45" width="225" height="180" rx="10" fill="#dcfce7" stroke="#16a34a" stroke-width="1.5"/>
    <text x="612" y="68" font-size="12" font-weight="700" fill="#052e16" text-anchor="middle">async/await over fetch</text>
    <text x="612" y="84" font-size="9" fill="#052e16" text-anchor="middle">~2017 · modern default</text>
    <g font-size="9" font-family="ui-monospace,monospace" fill="#052e16">
      <text x="512" y="116">const r = await fetch(url)</text>
      <text x="512" y="132">const data = await r.json()</text>
      <text x="512" y="160">try { … }</text>
      <text x="512" y="176">catch (e) { … }</text>
    </g>
    <text x="612" y="208" font-size="9" font-style="italic" fill="#64748b" text-anchor="middle">linear · readable</text>
  </g>
</svg>
```

## 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](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](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](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](https://javascript.info/fetch)

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

```quiz
Q: fetch rejects the promise (goes to .catch) when…
- the server returns a 4xx or 5xx status
- there's a network failure (couldn't reach the server); HTTP error statuses are still fulfilled promises
correct: 1
explain: fetch only rejects on network-level failure. A 404 or 500 resolves successfully — you must check response.ok (true for 200–299) yourself and throw if you want to treat HTTP errors as failures.

Q: Why is response.json() awaited separately after awaiting fetch()?
- fetch() resolves when the response headers arrive; the body is a stream that json() parses asynchronously, returning another promise
- It's redundant; you can await just once
correct: 0
explain: Two waits: one for the headers (fetch resolves), one for the body. response.json() reads and parses the body stream, so it returns a promise. Both are async operations.

Q: How do you cancel an in-flight fetch request in modern JavaScript?
- Pass an AbortController's signal in the fetch options, then call controller.abort()
- There is no way; fetch can't be cancelled
correct: 0
explain: AbortController is the standard cancellation mechanism. Create one, pass its signal to fetch, and call .abort() when you want to stop (e.g., in a React cleanup function). Pre-AbortController, fetch couldn't be cancelled.

Q: When would you still reach for XMLHttpRequest today?
- Never — fetch is strictly superior in every case
- Maintaining legacy code, or the rare feature fetch lacks (e.g., upload progress events before fetch supported them)
correct: 1
explain: fetch covers almost everything in modern code. XHR remains for legacy maintenance and a few edge features. New code should default to fetch.

Q: For complex needs like retries, caching, and deduplication, the modern approach is…
- rebuild all of it by hand with fetch
- use a data-fetching library (SWR, React Query, Apollo) layered over fetch
correct: 1
explain: fetch is the HTTP primitive. Concerns like caching, retries, and request deduplication are handled by libraries built on top of it — they call fetch internally. Rolling those by hand reinvents solved problems.
```
