articles / Data Fetching in the Browser: From XMLHttpRequest to fetch

Data Fetching in the Browser: From XMLHttpRequest to fetch

Data Fetching in the Browser: From XMLHttpRequest to fetch

The ability to request data in the background, without reloading the whole page, is what made interactive web applications possible. The technique was named AJAX, and for years it meant working with the XMLHttpRequest object and a tangle of callbacks. The modern fetch API does the same job with far less ceremony.

The old way and its workarounds

XMLHttpRequest worked, but its event-and-callback style was awkward, and chaining several requests led to deeply nested code that was hard to follow. There was also a hard limit: browsers block requests to a different origin by default. Before cross-origin sharing was standardized, developers worked around this with a trick that loaded data through a script tag, since script tags were not subject to the same restriction. That trick solved a real problem but came with security trade-offs, and it is now obsolete.

The evolution of browser data fetching A left-to-right sequence: XMLHttpRequest, then JSONP as a cross-origin workaround, then Promises, then the fetch API, then async and await, then AbortController for cancellation. XHR callbacks

JSONP workaround

Promises .then chains

fetch built in

async/await reads linear

Abort cancellable

From callbacks to cancellable, linear-reading requests: each step kept the capability and shed the ceremony of the one before.

Promises and fetch

fetch returns a promise, an object representing a value that will arrive later. Promises can be chained, and combined with async and await they read almost like ordinary sequential code.

async function loadUser(id) {
  const response = await fetch(`/api/users/${id}`);
  if (!response.ok) {
    throw new Error(`Request failed: ${response.status}`);
  }
  return response.json();
}

The function pauses at each await until the work finishes, then continues. The result is linear and easy to read, with no nesting.

Error handling that actually catches errors

One detail trips up almost everyone: fetch only rejects on a network failure. A response with a 404 or 500 status is still considered a successful request, so the promise resolves. The status must be checked explicitly, which is what the response.ok guard above does.

try {
  const user = await loadUser(42);
  render(user);
} catch (error) {
  showError(error.message);
}
A 500 response will not land in your catch block unless you check response.ok first. Treat any non-2xx status as a failure explicitly, or the error path silently never runs.

Cancelling and timing out

Requests sometimes need to be abandoned, for example when a user types a new search before the previous one returns. An AbortController provides a signal that cancels a fetch on demand.

const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);

const res = await fetch('/api/slow', { signal: controller.signal });
clearTimeout(timeout);

Sending data, not just reading it

Most examples show reading data, but the same API sends it. A request that changes something on the server uses a method such as POST, a header describing the body format, and a serialized body. The shape is the same as a read; only the options differ.

await fetch('/api/notes', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ title, body })
});

For file uploads or classic form submissions, a FormData object can be passed as the body instead, and the browser sets the right headers automatically. The status check from earlier still applies: a write that returns a 422 or 500 is a successful request with a failed outcome, so the response status must be inspected before assuming the change took effect.

Running requests in parallel

Awaiting requests one after another is the right tool when each depends on the previous result. When they are independent, doing so wastes time, because each await blocks the next from starting. Promise.all starts them together and waits for the whole set.

const [user, posts, settings] = await Promise.all([
  fetch('/api/user').then((r) => r.json()),
  fetch('/api/posts').then((r) => r.json()),
  fetch('/api/settings').then((r) => r.json())
]);

One caveat: Promise.all rejects as soon as any single request fails, discarding the rest. When partial success is acceptable, Promise.allSettled waits for every request and reports each outcome separately, so one failure does not sink the others. Choosing between the two is really a question of whether the results are all-or-nothing or independently useful.

The same controller can drive a timeout, as shown, or be wired to a cancel button. Combined, fetch, promises, async/await, Promise.all, and AbortController cover the large majority of data-fetching needs without any library at all.

Common questions

Does fetch throw an error on a 404 response?

No. fetch only rejects on a network-level failure. A 404 or 500 still resolves successfully, so you must check response.ok or the status code yourself and treat non-2xx responses as errors.

What was JSONP and is it still needed?

JSONP was a workaround that loaded cross-origin data through a script tag before cross-origin resource sharing existed. It is obsolete and carries security risks. Use CORS-enabled endpoints with fetch instead.

How do I cancel a fetch request?

Create an AbortController, pass its signal in the fetch options, and call controller.abort() when you want to cancel. This is common for search-as-you-type and for enforcing timeouts.

What is the relationship between async/await and promises?

async/await is syntax built on promises. An async function returns a promise, and await pauses until a promise settles. It lets asynchronous code read like sequential code without breaking the underlying promise model.