How the Fetch API Works Compared to XMLHttpRequest for HTTP Requests

The Fetch API provides a promise-based, streaming-capable interface for HTTP requests that replaces the callback-driven XMLHttpRequest, offering cleaner async/await syntax and more granular control over request cancellation and response processing.

The evolution from XMLHttpRequest to the Fetch API represents a fundamental shift in how JavaScript handles HTTP communications. According to the leonardomso/33-js-concepts repository—a comprehensive collection of essential JavaScript concepts—the Fetch API vs XMLHttpRequest comparison highlights modern asynchronous patterns that have become standard in web development. This guide examines the architectural differences, practical implementations, and advanced capabilities that distinguish these two APIs based on the source code analysis in docs/concepts/http-fetch.mdx.

Core Architectural Differences Between Fetch API and XMLHttpRequest

Promise-Based vs Callback-Driven Models

The Fetch API returns a Promise that resolves to a Response object as soon as the response headers are available, enabling natural integration with async/await syntax. In contrast, XMLHttpRequest relies on event callbacks (onload, onerror, onreadystatechange) attached to a mutable request object.

As documented in docs/concepts/http-fetch.mdx, the Fetch API resolves its promise when headers arrive, allowing the body to be processed as a stream, whereas XHR buffers the entire response before triggering the load event.

Request and Response Object Design

Fetch separates concerns into immutable Request and Response objects, encouraging a functional programming style. XMLHttpRequest combines request configuration and response handling on the same mutable object, requiring sequential method calls (open(), setRequestHeader(), send()) to configure the request.

Practical Implementation Comparison

Basic GET Requests

Fetch API implementation using async/await:

async function loadUser(id) {
  const response = await fetch(`https://api.example.com/users/${id}`);

  // Detect HTTP errors
  if (!response.ok) {
    throw new Error(`HTTP error! status: ${response.status}`);
  }

  const user = await response.json();
  console.log(user);
}

XMLHttpRequest implementation using callbacks:

function loadUserXHR(id) {
  const xhr = new XMLHttpRequest();
  xhr.open('GET', `https://api.example.com/users/${id}`);
  xhr.onload = function () {
    if (xhr.status >= 200 && xhr.status < 300) {
      const user = JSON.parse(xhr.responseText);
      console.log(user);
    } else {
      console.error('HTTP error', xhr.status);
    }
  };
  xhr.onerror = function () {
    console.error('Network error');
  };
  xhr.send();
}

POST Requests with JSON

Fetch API POST request:

async function createUser(data) {
  const response = await fetch('https://api.example.com/users', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(data),
  });

  if (!response.ok) throw new Error(`Failed: ${response.status}`);
  return await response.json();
}

XMLHttpRequest POST request:

function createUserXHR(data) {
  const xhr = new XMLHttpRequest();
  xhr.open('POST', 'https://api.example.com/users');
  xhr.setRequestHeader('Content-Type', 'application/json');

  xhr.onload = function () {
    if (xhr.status >= 200 && xhr.status < 300) {
      console.log(JSON.parse(xhr.responseText));
    } else {
      console.error('HTTP error', xhr.status);
    }
  };
  xhr.onerror = () => console.error('Network error');
  xhr.send(JSON.stringify(data));
}

Request Cancellation

Fetch API cancellation using AbortController:

const controller = new AbortController();
fetch('/slow-endpoint', { signal: controller.signal })
  .then(r => r.json())
  .catch(err => {
    if (err.name === 'AbortError') console.log('Request canceled');
    else console.error(err);
  });

// Cancel after 2 seconds
setTimeout(() => controller.abort(), 2000);

XMLHttpRequest cancellation:

const xhr = new XMLHttpRequest();
xhr.open('GET', '/slow-endpoint');
xhr.onload = () => console.log(xhr.responseText);
xhr.onerror = () => console.error('Network error');
xhr.send();

// Abort after 2 seconds
setTimeout(() => xhr.abort(), 2000);

While both APIs support cancellation, the AbortController pattern integrates seamlessly with modern promise chains and async/await syntax, whereas XHR's abort() method requires manual state management within callback structures.

Advanced Capabilities and Streaming

ReadableStream Support in Fetch API

The Fetch API treats response bodies as ReadableStream objects, enabling progressive consumption of data without buffering the entire payload. This is particularly valuable for processing large files or implementing progress indicators. XMLHttpRequest buffers the complete response before triggering the load event, consuming more memory for large transfers.

As noted in docs/concepts/http-fetch.mdx, the streaming capability represents a fundamental architectural advantage of Fetch over XHR, allowing developers to handle data chunks as they arrive rather than waiting for complete transmission.

AbortController Integration

Modern request cancellation relies on the AbortController interface, which provides a standardized way to abort DOM requests including Fetch. The controller's signal property connects to the fetch request, and calling abort() triggers a promise rejection with an AbortError. This pattern, validated in tests/web-platform/http-fetch/http-fetch.test.js, provides more ergonomic cancellation than XHR's imperative abort() method, especially when managing multiple concurrent requests or integrating with React hooks and other modern frameworks.

Error Handling Patterns

The Fetch API and XMLHttpRequest handle errors differently, which often causes confusion when migrating legacy code.

Fetch API error handling requires explicit checks for HTTP error status codes because the promise only rejects on network failures (DNS errors, connection refused, CORS violations). As documented in docs/concepts/http-fetch.mdx under "The #1 Fetch Mistake," developers must verify response.ok or response.status before processing the body.

XMLHttpRequest error handling conflates network and HTTP errors within the same callback structure. The onerror callback fires for network failures, while HTTP errors (404, 500) must be detected by checking xhr.status inside the onload handler.

The test suite in tests/web-platform/http-fetch/http-fetch.test.js validates these behaviors, ensuring that Fetch properly distinguishes between network-level rejections and HTTP-level non-ok responses.

Summary

  • The Fetch API provides a promise-based interface that integrates naturally with async/await, while XMLHttpRequest relies on event callbacks and mutable state.
  • Fetch separates request and response into immutable objects and supports streaming via ReadableStream, whereas XHR buffers the entire response before processing.
  • Error handling differs significantly: Fetch promises reject only on network failures, requiring explicit response.ok checks for HTTP errors, while XHR uses onerror for network issues and status checks within onload for HTTP errors.
  • Cancellation is implemented via AbortController in Fetch (promise-friendly) and xhr.abort() in XHR (callback-based).
  • According to the leonardomso/33-js-concepts repository, modern JavaScript development favors Fetch for new code, while XHR remains relevant for legacy browser support and synchronous requests (though synchronous XHR is discouraged).

Frequently Asked Questions

Is Fetch API better than XMLHttpRequest for all use cases?

While the Fetch API is generally preferred for modern applications due to its promise-based architecture and streaming capabilities, XMLHttpRequest still serves specific scenarios. XHR supports synchronous requests (though deprecated and discouraged), offers progress tracking via onprogress events for uploads and downloads, and provides wider compatibility with very old browsers. For most contemporary applications using async/await and requiring clean cancellation via AbortController, Fetch is the superior choice.

Why does Fetch not reject on HTTP error status codes like 404 or 500?

The Fetch API treats HTTP responses as successful network operations regardless of status code, resolving the promise as long as the server responds. This design follows the Fetch Living Standard, which distinguishes between network errors (connection failures, DNS issues, CORS violations) and application-level HTTP responses. Developers must explicitly check response.ok (which verifies status 200-299) or response.status to handle HTTP errors. This pattern, documented in docs/concepts/http-fetch.mdx as "The #1 Fetch Mistake," requires adjustment when migrating from XHR where error handling is more conflated.

Can I use Fetch API in Node.js environments?

Yes, but with version considerations. Node.js added native Fetch API support in version 18 as an experimental global and stabilized it in later versions. Prior to Node 18, developers relied on polyfills like node-fetch or cross-fetch to use Fetch in server-side JavaScript. The package.json in the leonardomso/33-js-concepts repository indicates a modern testing environment using Vitest, which supports the native Fetch implementation. For universal JavaScript code that runs in both browsers and Node.js, ensure you're targeting Node 18+ or include appropriate polyfills.

How do I track upload or download progress with Fetch API?

Unlike XMLHttpRequest, which provides onprogress events for granular progress tracking, the Fetch API handles progress monitoring through ReadableStream consumption. To track download progress, you access response.body (a ReadableStream) and manually read chunks while calculating progress against the Content-Length header if available. For upload progress with Fetch, you must create a ReadableStream for the request body and track bytes as they are consumed. This approach is more verbose than XHR's event-based progress tracking but offers greater flexibility for transforming streams during transfer. As noted in docs/concepts/http-fetch.mdx, XHR remains preferable when simple progress bars are required without additional stream complexity.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →