Amblem
Furkan Baytekin

Fetch vs XHR: A Deep Dive into Web Dev’s Past and Present

Build your own fetch API polyfill & understand modern web request handling

Fetch vs XHR: A Deep Dive into Web Dev’s Past and Present
94
7 minutes

Let’s talk about two staples of HTTP requests in JavaScript: the fetch() API and XMLHttpRequest (XHR). You’re probably using fetch() daily—it’s the modern standard, after all—but XHR? It’s more like that vintage vinyl player in the corner: not spinning much these days, but it’s got a story worth knowing. Understanding fetch versus XHR isn’t about picking a winner (spoiler: fetch already won); it’s about appreciating where we’ve been and why fetch rules the roost in 2025. So, let’s break it down—history, mechanics, and all—with some code to keep it real.

The Backstory: XHR’s Glory Days and Fetch’s Rise

Picture this: it’s the late ‘90s, and the web is mostly static pages and dial-up screeches. Microsoft drops XMLHttpRequest into IE5 as an ActiveX oddity. Fast forward to the mid-2000s, and XHR becomes the backbone of AJAX—those slick, no-refresh updates powering Gmail and Google Maps. It was a game-changer, letting devs snag data asynchronously with a clunky but effective API. Every browser adopted it, and XHR reigned supreme for years.

Then, in 2015, fetch() arrived, cooked up by the WHATWG crew and rolled out in modern browsers like Chrome and Firefox. It wasn’t just a new toy—it was a rethink. Promise-based, streamlined, and free of XHR’s baggage, fetch() was built for a JavaScript world that had outgrown callbacks. By 2025, XHR’s a relic; fetch() is baked into Node.js (since v18) and powers everything from SPAs to APIs. But how’d we get from there to here? Let’s compare.

How They Work: Fetch vs XHR Under the Hood

Both fetch() and XHR fetch data from servers, but their approaches? Night and day. Here’s the breakdown with examples to show the shift.

1. Syntax: Old School vs New School

XHR is verbose and event-driven—think manual gears. Fetch is Promise-based and sleek. Here’s a simple GET request:

XHR: The Classic Way

javascript
const xhr = new XMLHttpRequest(); xhr.open('GET', 'https://api.example.com/users', true); xhr.onload = function () { // Yes, this was a best practice back in the day. if (xhr.status >= 200 && xhr.status < 300) { const data = JSON.parse(xhr.responseText); console.log('Users:', data); } else { console.error('Error:', xhr.status); } }; xhr.onerror = function () { console.error('Network issue!'); }; xhr.send();

Fetch: The Modern Way

javascript
// Defaults to GET method fetch('https://api.example.com/users') .then(response => { if (!response.ok) throw new Error(`Status: ${response.status}`); return response.json(); }) .then(data => console.log('Users:', data)) .catch(error => console.error('Oops:', error));

Or, with async/await:

javascript
async function getUsers() { try { const response = await fetch('https://api.example.com/users'); if (!response.ok) throw new Error('Something’s off!'); const data = await response.json(); console.log('Users:', data); } catch (error) { console.error('Oops:', error); } } getUsers();

XHR’s a slog—setting up events, parsing JSON manually. Fetch hands you a clean chain and built-in methods like response.json(). It’s no wonder XHR’s faded out.

2. Error Handling

XHR flags errors via status codes in onload—you sort it out. Fetch only rejects Promises on network failures; HTTP errors like 404 or 500 resolve as “successful” unless you check response.ok.

XHR: Status Check

javascript
xhr.onload = function () { if (xhr.status === 404) { console.error('Not found!'); } else if (xhr.status === 200) { console.log(JSON.parse(xhr.responseText)); } };

Fetch: Watch That ok

javascript
fetch('https://api.example.com/missing') .then(response => { if (!response.ok) throw new Error('Not found!'); return response.json(); }) .catch(error => console.error(error));

3. Features: What They Bring to the Table

XHR had some neat tricks—like onprogress for tracking uploads:

javascript
xhr.upload.onprogress = event => { const percent = Math.round((event.loaded / event.total) * 100); console.log(`Uploaded ${percent}%`); };

Fetch doesn’t do this natively (streams can approximate it), but it counters with modern wins: readable streams for big data and AbortController for canceling requests:

Aborting a Request

javascript
const controller = new AbortController(); fetch('https://api.example.com/big-data', { signal: controller.signal }) .then(response => response.json()) .catch(err => console.error(err)); setTimeout(() => controller.abort(), 5000); // Bail after 5s

Progress bar with fetch

javascript
async function downloadWithProgress(url) { const response = await fetch(url); const contentLength = response.headers.get('content-length'); const total = parseInt(contentLength) || 0; let loaded = 0; const reader = response.body.getReader(); const chunks = []; while (true) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); loaded += value.length; const progress = total ? (loaded / total) * 100 : 'unknown'; console.log(`Downloaded ${progress.toFixed(2)}%`); const progressBar = document.querySelector('#progress'); if (progressBar) { progressBar.style.width = `${progress}%`; progressBar.textContent = `${progress.toFixed(1)}%`; } } const blob = new Blob(chunks); return blob; } downloadWithProgress('https://api.example.com/large-file') .then(blob => console.log('Download complete:', blob)) .catch(error => console.error('Download failed:', error));

4. POST Requests: Sending Stuff

Both can POST, but fetch simplifies it:

XHR POST

javascript
xhr.open('POST', 'https://api.example.com/users', true); xhr.setRequestHeader('Content-Type', 'application/json'); xhr.send(JSON.stringify({ name: 'Furkan' }));

Fetch POST

javascript
fetch('https://api.example.com/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Furkan' }) }) .then(response => response.json() );

Fetch’s options object is just more ergonomic.

Browser Support: Where They Stand in 2025

Today, fetch() is everywhere: Chrome, Firefox, Safari, Edge, Node.js. It’s the default, no questions asked. XHR? It’s universal too, even in ancient IE6, nobody’s running IE11 unless they’re stuck in a corporate time capsule. Fetch’s dominance is complete, though older codebases might still lean on XHR for historical reasons.

Why Fetch Took Over

Fetch didn’t just edge out XHR—it lapped it. Promises and async/await fit today’s JavaScript like a glove, and fetch’s streamlined API cuts the cruft. It’s built for streams, service workers, and a web that’s all about real-time and scale. XHR’s still got niche uses (progress tracking, anyone?), but it’s a museum piece in 2025—respected, not used.

A Fetch Polyfill: Bridging the Past

For fun, here’s a simple fetch polyfill using XHR—think of it as a history lesson in code. It’s not for daily use (fetch is native now), but it shows how to mimic fetch in a pinch:

javascript
if (!window.fetch) { window.fetch = function (url, options) { if (!options) { options = {}; } return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); const method = (options.method || 'GET').toUpperCase(); xhr.open(method, url, true); if (options.headers) { for (const [key, value] of Object.entries(options.headers)) { xhr.setRequestHeader(key, value); } } xhr.responseType = options.responseType || 'text'; xhr.onload = function () { const response = { ok: xhr.status >= 200 && xhr.status < 300, status: xhr.status, statusText: xhr.statusText, json: () => Promise.resolve( xhr.responseType === 'json' ? xhr.response : JSON.parse(xhr.responseText || xhr.response) ), text: () => Promise.resolve( xhr.responseType === 'text' ? xhr.response : String(xhr.response) ), blob: () => Promise.resolve( xhr.responseType === 'blob' ? xhr.response : new Blob([xhr.responseText || xhr.response]) ) }; if (response.ok) { resolve(response); } else { reject(new Error(`Status: ${xhr.status}`)); } }; xhr.onerror = () => reject(new Error('Network error')); xhr.send(method === 'GET' || method === 'HEAD' ? null : options.body); }); }; } // Test it fetch('https://api.example.com/users') .then(res => res.json()) .then(data => console.log(data)) .catch(err => console.error(err));

Step-by-Step:

  1. Check Support: If fetch isn’t there, define it.
  2. Options: If no options are provided, create an empty object.
  3. Promise It Up: Wrap XHR in a Promise to match fetch’s style.
  4. Map Options: Translate fetch’s method, headers, and body to XHR.
  5. Fake a Response: Build a fetch-like object with ok, json(), etc.
  6. Handle Errors: Reject on network issues or bad statuses.

It’s barebones—no streams or aborting—but it mimics fetch’s core. Real-world polyfills like whatwg-fetch do more, but this shows the bridge from XHR to fetch.

Final Thoughts

Fetch vs XHR isn’t a debate anymore—fetch is the present and future. Still, knowing XHR’s quirks and legacy gives you context for why fetch feels so good. It’s like appreciating black-and-white TV before streaming 4K. Next time you’re chaining a .then() or awaiting a response, give a nod to XHR—it got us here.


Album of the day:

Suggested Blog Posts