fetch() .catch() with XMLHttpRequest Fallback: When Modern APIs Still Fail

You switched to fetch() because it was supposed to be cleaner, more modern, and easier to work with than XMLHttpRequest. The promise chain reads naturally. The .catch() at the end feels reassuring. The problem is that fetch() and .catch() have a complicated relationship — and in environments where both the modern API and the network itself are unreliable, you sometimes need the old way waiting in reserve.

The pattern of trying fetch() first and falling back to XMLHttpRequest if it still fails sounds straightforward. In practice, it requires understanding a quirk that trips up many JavaScript developers: fetch() resolves successfully even when a server returns a 404 or 500. The .catch() handler you carefully placed at the end of your chain may never run — not because everything worked, but because fetch() does not consider HTTP errors to be errors worth rejecting over.

This article works through how the two APIs handle errors differently, how to construct a fallback chain that accounts for fetch()'s silent behavior, and when the XHR fallback pattern remains a rational choice rather than a nostalgic one.

Why fetch() and .catch() Are Not the Same Thing

The fetch() function returns a Promise. Promises reject — and therefore route to .catch() — under a specific set of conditions. For fetch(), that set is narrow. According to the MDN Web Docs, fetch() only rejects when a network error is encountered: a failed DNS lookup, a connection that drops before the server responds, or a CORS preflight that fails entirely. HTTP status codes — 404, 500, 429, anything in the 4xx and 5xx range — do not cause rejection.

"A fetch() promise only rejects when the request fails, for example, because of a badly-formed request URL or a network error. A fetch() promise does not reject if the server responds with HTTP status codes that indicate errors (404, 504, etc.)." — MDN Web Docs, Window: fetch() method (last updated Dec 16, 2025)

This means that the following code, which looks like it handles errors, actually does not:

// This looks safe. It is not.
fetch('/api/data')
  .then(response => response.json())
  .then(data => console.log(data))
  .catch(error => console.error('Something went wrong:', error));

// If the server returns a 404 or 500, .catch() never runs.
// The Promise resolves. response.json() may throw a SyntaxError
// if the body is HTML (like an error page), but the HTTP
// failure itself is invisible to .catch().
Common Misconception

Developers coming from jQuery's $.ajax() or from Axios expect .catch() to fire on any bad HTTP response. Both of those libraries reject on 4xx and 5xx status codes by default. Native fetch() does not. Switching from Axios to native fetch() without accounting for this difference is a frequent source of silent failures in production.

To make fetch() actually fail in a way that routes to .catch(), you have to throw manually. The conventional approach checks the response.ok property, which is true only when the HTTP status code falls between 200 and 299 inclusive. If it is false, you throw an error yourself, and that error then propagates to the .catch() handler:

fetch('/api/data')
  .then(response => {
    // Manually surface HTTP errors as rejections
    if (!response.ok) {
      throw new Error(`HTTP error: ${response.status}`);
    }
    return response.json();
  })
  .then(data => console.log(data))
  .catch(error => {
    // Now catches both network failures AND HTTP errors
    console.error('Request failed:', error.message);
  });

As web.dev's article on Implement error handling when using the Fetch API notes, the right approach is to check the response status and throw if the request was not successful — either using response.ok, response.status, or other response metadata. Skipping that check is the source of the "silent success" problem that causes incorrect fallback behavior later in a chain.

The XHR Fallback Pattern Explained

Even with response.ok guarding your fetch() call, there are still environments and failure modes where XMLHttpRequest is a rational fallback choice. fetch()'s global coverage as of March 2026 sits at 97.21% according to Can I Use data, which sounds comfortable until you consider that it provides zero support across all editions of Internet Explorer and has no support in Opera Mini at all. Enterprise environments running old browsers, embedded systems with constrained runtimes, and certain corporate proxy configurations have been known to break fetch() in ways that XHR handles without issue.

One practitioner on the LogRocket Blog described a real-world scenario: their company's corporate proxy used cookies in a way that caused fetch()'s stricter CORS handling to break all requests to public APIs, while XHR — which sends cookies by default — continued to work. The fix was to force the XHR-based polyfill on all browsers to bypass the issue entirely. That is an edge case, but it is precisely the kind of edge case where a fallback chain earns its existence.

The basic fallback pattern wraps the fetch() call in a function, catches any failure at that level, and then invokes an XHR-based equivalent as the recovery path:

// XHR wrapped in a Promise so it fits the same chain
function xhrFallback(url) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open('GET', url, true);

    xhr.onload = function () {
      if (xhr.status >= 200 && xhr.status < 300) {
        try {
          resolve(JSON.parse(xhr.responseText));
        } catch (e) {
          reject(new Error('XHR: failed to parse response JSON'));
        }
      } else {
        reject(new Error(`XHR HTTP error: ${xhr.status}`));
      }
    };

    xhr.onerror = function () {
      reject(new Error('XHR: network error'));
    };

    xhr.ontimeout = function () {
      reject(new Error('XHR: request timed out'));
    };

    xhr.timeout = 8000;
    xhr.send();
  });
}

// The fetch() call with a proper .catch() that falls back to XHR
function fetchWithFallback(url) {
  return fetch(url)
    .then(response => {
      if (!response.ok) {
        throw new Error(`fetch HTTP error: ${response.status}`);
      }
      return response.json();
    })
    .catch(fetchError => {
      console.warn('fetch() failed, trying XHR fallback:', fetchError.message);
      return xhrFallback(url);
    });
}

// Usage
fetchWithFallback('/api/data')
  .then(data => console.log('Got data:', data))
  .catch(finalError => {
    // Both fetch AND XHR have failed
    console.error('All transports failed:', finalError.message);
  });
Pro Tip

Wrapping XHR in a Promise is the key move here. It lets the fallback slot directly into a .then()/.catch() chain without switching to a callback-based mental model mid-function. You get the old API's reliability with the modern interface's composability.

Note that XHR's onload handler fires for any completed response — including 4xx and 5xx responses — so you still need to check xhr.status explicitly. Unlike fetch()'s response.ok, there is no shorthand: you write the range check yourself (xhr.status >= 200 && xhr.status < 300). This is actually one place where XHR is more explicit about what you are doing, even if it is less ergonomic.

The Double-Catch: When the Fallback Itself Fails

The chain above has a structural issue worth naming: the .catch() that triggers the XHR fallback returns a new Promise. If that XHR Promise rejects — because the server is genuinely down, the endpoint does not exist, or both transports fail for the same underlying reason — the rejection propagates to the next .catch() in the chain. This is the double-catch pattern: one handler for the fetch() failure, a second handler for the total failure of both paths.

// Async/await version — often clearer for the double-catch logic
async function fetchWithFallback(url) {
  // Attempt 1: fetch()
  try {
    const response = await fetch(url);
    if (!response.ok) {
      throw new Error(`fetch HTTP ${response.status}`);
    }
    return await response.json();
  } catch (fetchErr) {
    console.warn('fetch() failed:', fetchErr.message, '— trying XHR');
  }

  // Attempt 2: XHR
  try {
    return await xhrFallback(url);
  } catch (xhrErr) {
    // Both paths exhausted — throw a unified error
    throw new Error(`All transports failed. XHR: ${xhrErr.message}`);
  }
}

// Caller handles total failure
fetchWithFallback('/api/data')
  .then(data => renderData(data))
  .catch(err => showOfflineBanner(err.message));

The async/await version is often clearer for this pattern because the two try/catch blocks visually separate the two attempts. Each catch block has a single responsibility: the first escalates to XHR, the second signals complete failure to the caller. This also makes logging cleaner — you can record which transport failed and why, which matters when you are debugging an intermittent production issue across browser environments.

Note

When the .catch() inside a Promise chain returns a value (or a resolved Promise), the chain continues as a success. When it throws or returns a rejected Promise, the failure propagates. This is how the fallback pattern works: the first .catch() returns the XHR Promise, so if XHR succeeds, the outer chain sees a success. If XHR fails, that rejection propagates to the final .catch(). The control flow is predictable once you understand that .catch() is not the end of the chain — it is a recovery handler that can re-enter the success path.

Comparison: fetch vs XHR Error Visibility

Understanding exactly what each API surfaces on failure is what makes the fallback pattern reliable rather than accidental. The table below maps failure modes to what each API does with them by default — before any manual error-checking code is added.

Failure Mode fetch() default behavior XHR default behavior
Network unreachable / DNS failure Rejects — .catch() fires onerror fires
HTTP 404 Not Found Resolves — .catch() does NOT fire; response.ok = false onload fires; status = 404; must check manually
HTTP 500 Internal Server Error Resolves — .catch() does NOT fire; response.ok = false onload fires; status = 500; must check manually
CORS preflight failure Rejects — .catch() fires onerror fires (less detail exposed)
Request aborted by controller Rejects with AbortError — .catch() fires onabort fires after xhr.abort()
Timeout No native timeout (requires AbortController + setTimeout) ontimeout fires when xhr.timeout exceeded
Response body parse failure response.json() rejects with SyntaxError JSON.parse() throws synchronously in onload

The timeout row is worth pausing on. XMLHttpRequest has a native timeout property that triggers ontimeout cleanly. fetch() has no native timeout — to replicate it, you use AbortController combined with a setTimeout. According to WaspDev's October 2025 analysis of whether fetch() has caught up with XHR, fetch() is now mature and surpasses XHR in areas like streaming and keepalive support, but XHR's native timeout and upload progress tracking remain advantages for specific use cases. That analysis also notes that library authors, who want to cover all cases, often keep XHR as a fallback for exactly this reason.

When This Pattern Still Makes Sense in 2026

With fetch() at 97.21% global browser coverage as of March 2026, the case for an XHR fallback has narrowed considerably. There are still specific scenarios where the pattern is justified rather than purely defensive over-engineering.

The first is when you are supporting a known legacy environment. Internal enterprise tools, government systems, and applications with verified IE11 or older-Edge users cannot rely on fetch(). In those deployments, the XHR fallback is not optional — it is the primary transport.

The second is proxy interference. As the LogRocket case illustrates, corporate network proxies can break fetch()'s CORS behavior in ways that XHR does not trigger, because XHR sends cookies by default and has historically been more forgiving with certain proxy configurations. If your application serves users on corporate networks and you are seeing intermittent failures that only appear in certain offices or behind VPNs, the fallback pattern gives you a diagnostic tool as well as a recovery path.

The third is when you genuinely need upload progress. fetch()'s ReadableStream body supports download streaming, but true upload progress events — the kind that power a real-time progress bar during a file upload — are still an XHR-native feature. The WaspDev October 2025 analysis of fetch versus XHR confirms that fetch() does not yet support true upload progress, while XHR's xhr.upload.onprogress event remains the only reliable cross-browser mechanism for it. Jake Archibald's 2025 post on his blog (jakearchibald.com) makes the same point clearly: streaming a ReadableStream request body through fetch() can approximate upload progress in Chromium-based browsers, but the approach doesn't work reliably across all browsers and the numbers it produces are not always accurate.

What's Changing: The Fetch Upload Progress Gap Is Closing — Slowly

Engineers from Igalia are actively working on a native fetch() observer API that would add onprogress events for both uploads and downloads directly in the fetch() options object — no XHR workaround required. The API is not yet stable and its shape may change before it ships. Separately, the Interop 2026 initiative (a joint effort from Apple, Google, Igalia, Microsoft, and Mozilla) includes ReadableStream in the fetch() request body as an explicit focus area, targeting cross-browser consistency for streaming uploads. Until both of these land with broad browser support, XHR is still the only production-safe path for upload progress tracking in the browser. The fallback pattern described in this article remains the right tool during that transition window.

Outside of these cases, the honest answer is that the pattern is often better replaced by a fetch wrapper library like ky or ofetch, which normalize error handling (including throwing on non-2xx responses), add built-in retry logic, and support timeout without requiring you to manage AbortController manually. For most applications in 2026, those wrappers are a cleaner solution than manually maintaining a fetch-to-XHR fallback chain.

Pro Tip

Before reaching for a full XHR fallback, consider whether ky or ofetch solves your problem. Both throw on non-2xx responses by default, support retry with exponential backoff, and handle timeouts cleanly through AbortController internally. If your only concern is that fetch()'s .catch() does not catch HTTP errors, a wrapper is a lower-maintenance fix than a custom XHR fallback layer.

If you do maintain the fallback pattern, one additional consideration is feature detection. Rather than always attempting fetch() first, some implementations gate on availability:

// Feature-detect before attempting fetch()
async function smartRequest(url) {
  if (typeof fetch === 'function') {
    try {
      const response = await fetch(url);
      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      return await response.json();
    } catch (err) {
      console.warn('fetch() failed, falling back to XHR:', err.message);
    }
  }
  // XHR path — used when fetch() is absent OR when fetch() fails
  return xhrFallback(url);
}

// One-liner feature check before the full chain
if (!window.fetch) {
  // Polyfill or skip to XHR entirely
}

Feature detection of this kind aligns with the graceful degradation principle: design for the modern environment first, ensure the core function works in reduced-capability environments. The SitePoint analysis of XMLHttpRequest versus fetch notes that polyfill libraries such as github/fetch and the minimal unfetch (around 500 bytes) use XHR as their internal fallback, which is exactly this pattern in production-grade form.

Key Takeaways

  1. fetch() does not reject on HTTP errors by default. A 404 or 500 resolves the Promise with response.ok set to false. Your .catch() will not run unless you check response.ok and throw manually.
  2. Wrap XHR in a Promise to build a composable fallback. Returning a Promise from the .catch() handler lets the XHR fallback slot cleanly into the existing chain without switching mental models mid-function.
  3. Use two separate try/catch blocks in async/await form. The first handles fetch() failure and triggers the fallback; the second handles total fallback failure and surfaces a unified error to the caller.
  4. XHR is still the only browser option for upload progress and has native timeout support. For those specific use cases, the fallback pattern is not a workaround — it is the right tool.
  5. For new projects, consider a fetch wrapper before building a manual fallback. Libraries like ky and ofetch give you correct error semantics, retry logic, and timeout handling out of the box.

The fetch-to-XHR fallback pattern is one of those constructs that looks like a historical artifact right up until the moment you need it. Knowing how fetch()'s error model works — and exactly where .catch() does and does not fire — is what separates a fallback that reliably recovers from one that silently passes bad data through the same chain it was supposed to protect.