articles / 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.
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);
}
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.