1Fetching JSON Data with XMLHttpRequest
▶
Long before the modern Fetch API arrived, browsers provided a built-in mechanism for making HTTP requests from JavaScript without reloading the page. That mechanism is XMLHttpRequest, commonly abbreviated as XHR. It was the engine behind the original wave of dynamic, "Web 2.0" applications, and it remains deeply embedded in a vast number of production codebases written before ES6 became widespread. Understanding XHR thoroughly means you can read, debug, and maintain that legacy code confidently — and you gain a deeper appreciation of what the Fetch API later improved upon.
XMLHttpRequest is a browser-native API, meaning it is available as a global constructor in every major browser, including browsers as old as Internet Explorer 7. Despite its name, XHR is not limited to XML at all. It can transfer any text-based or binary data format: plain text, HTML fragments, XML documents, and — most commonly today — JSON. The name is simply a historical artifact from the era when XML was the dominant data interchange format on the web.
Because XHR predates Promises and the async/await syntax, it operates through event-based callbacks. You create an object, attach handlers to its events, configure the request, and then send it. The browser calls your handlers when things happen — a response arrives, an error occurs, or upload progress is made. This callback-driven style is important to recognise when you encounter it in older code.
The overall lifecycle of an XHR request follows a clear sequence of steps:
- Instantiate a new
XMLHttpRequestobject. - Call
open()to configure the HTTP method, URL, and synchronous/asynchronous mode. - Attach event handlers such as
onloadandonerror. - Call
send()to dispatch the request to the server. - Inside the handlers, inspect the response and process the data.
Let's walk through each of these stages in depth.
Creating and Opening an XHR Request
Creating an XHR object is straightforward — you call the constructor with new:
const xhr = new XMLHttpRequest();
This gives you a fresh request object. Nothing has happened on the network yet; you simply have a configured object sitting in memory. The next step is calling open(), which sets up the parameters for the request:
xhr.open('GET', 'https://api.example.com/data/users.json', true);
The open() method accepts three important arguments:
- HTTP method — a string such as
'GET','POST','PUT', or'DELETE'. For simply retrieving JSON data you will almost always use'GET'. - URL — the full or relative URL of the resource you want to fetch.
- Asynchronous flag — a boolean. Passing
true(the default and strongly recommended value) makes the request asynchronous, meaning the browser sends the request in the background and continues executing your script. Passingfalsemakes it synchronous, which blocks the entire browser tab until the response arrives — a behaviour that is now deprecated in many contexts because it causes the UI to freeze and makes for a terrible user experience.
It is crucial to understand that calling open() does not send the request. It only prepares the request configuration internally. Think of it like addressing and stamping an envelope — you haven't dropped it in the mailbox yet.
Sending the Request and Handling the Response
Before sending, you should register your event handlers. The most important handler for a successful data retrieval is onload, which fires when the browser has fully received a response from the server:
xhr.onload = function () {
if (xhr.status === 200) {
const data = JSON.parse(xhr.responseText);
console.log(data);
} else {
console.error('Request failed. Status:', xhr.status);
}
};
Only after attaching your handlers do you call send():
xhr.send();
The reason for this ordering matters: although it is technically unlikely in practice, attaching handlers before calling send() guarantees that your callbacks are in place before any response could theoretically be processed. It is a safe, consistent habit.
Inside the onload handler, two properties on the XHR object are essential:
xhr.status— the HTTP status code returned by the server (e.g.,200for OK,404for Not Found,500for Internal Server Error). A critical point:onloadfires for any completed response, including error responses like 404. It only means the network transaction finished — not that the server gave you what you asked for. This is why checkingxhr.status === 200(or more broadly,xhr.status >= 200 && xhr.status < 300) before processing data is mandatory.xhr.responseText— the raw body of the response as a plain string. When the server returns JSON, this is the JSON-formatted text. You must parse it yourself before you can work with it as a JavaScript object.
Parsing JSON from the XHR Response
The raw text in xhr.responseText is not yet usable as a JavaScript data structure. To convert it, you call JSON.parse():
xhr.onload = function () {
if (xhr.status === 200) {
try {
const users = JSON.parse(xhr.responseText);
users.forEach(function (user) {
console.log(user.name, user.email);
});
} catch (e) {
console.error('Failed to parse JSON:', e.message);
}
}
};
JSON.parse() takes a valid JSON string and returns the corresponding JavaScript value — an object, an array, a number, a string, a boolean, or null. If the string is malformed or is not valid JSON (for example, the server returned an HTML error page instead of JSON), JSON.parse() throws a SyntaxError. Wrapping the call in a try/catch block is therefore good practice, especially when dealing with APIs you do not fully control.
A convenient alternative is to set the responseType property on the XHR object before calling send():
xhr.responseType = 'json';
When responseType is set to 'json', the browser automatically parses the response body for you. Instead of reading xhr.responseText, you read xhr.response, which will already be the parsed JavaScript object:
const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://api.example.com/data/users.json', true);
xhr.responseType = 'json';
xhr.onload = function () {
if (xhr.status === 200) {
const users = xhr.response; // already parsed — no JSON.parse() needed
console.log(users);
}
};
xhr.onerror = function () {
console.error('Network error occurred.');
};
xhr.send();
This approach is cleaner and eliminates the risk of a SyntaxError crashing your handler, since the parsing is handled internally. However, responseType = 'json' has slightly less universal browser support than responseText with manual parsing, which is why you will still see the manual approach in many older codebases.
Error Handling with XMLHttpRequest
Robust XHR code must handle two completely different categories of failure, and each requires a different approach.
The first category is a network-level failure — for example, the user is offline, the DNS lookup fails, or a firewall blocks the connection entirely. In these cases, no HTTP response is ever received. XHR surfaces these situations through the onerror event handler:
xhr.onerror = function () {
console.error('The request could not be completed due to a network error.');
};
The second category is an HTTP-level failure — the server was reached successfully but returned a status code indicating a problem, such as 404 Not Found or 500 Internal Server Error. Here is the critical subtlety that catches many developers off guard: onerror does not fire for 4xx or 5xx responses. From XHR's perspective, receiving a 404 is a perfectly successful network transaction — a response arrived. The onload handler fires, but the xhr.status is not 200. This is why status code checking inside onload is not optional:
xhr.onload = function () {
if (xhr.status >= 200 && xhr.status < 300) {
// Success path
const data = JSON.parse(xhr.responseText);
processData(data);
} else if (xhr.status === 404) {
console.error('Resource not found (404).');
} else if (xhr.status === 500) {
console.error('Server error (500).');
} else {
console.error('Unexpected status code:', xhr.status);
}
};
A complete, defensive XHR implementation that handles all scenarios looks like this:
function fetchUsers(url, onSuccess, onFailure) {
const xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
xhr.responseType = 'json';
xhr.onload = function () {
if (xhr.status >= 200 && xhr.status < 300) {
onSuccess(xhr.response);
} else {
onFailure(new Error('HTTP error: ' + xhr.status));
}
};
xhr.onerror = function () {
onFailure(new Error('Network error: request could not be sent.'));
};
xhr.send();
}
fetchUsers(
'https://api.example.com/users.json',
function (data) { console.log('Users loaded:', data); },
function (err) { console.error('Failed to load users:', err.message); }
);
This pattern — wrapping XHR in a utility function that accepts success and failure callbacks — was extremely common in pre-Promise JavaScript. You will encounter it frequently when working with older codebases or libraries like early versions of jQuery, which built its own AJAX utilities on top of XHR.
XHR vs. the Fetch API: Context and Legacy Use
It is worth placing XHR in historical context to understand why the Fetch API was created and why XHR still matters. The following table compares the two approaches across several key dimensions:
| Feature | XMLHttpRequest | Fetch API |
|---|---|---|
| Syntax style | Callback-based (event handlers) | Promise-based (.then() / async/await) |
| Browser compatibility | All browsers, including IE7+ | Modern browsers (IE not supported without a polyfill) |
| Automatic JSON parsing | Optional via responseType = 'json' |
Via response.json() returning a Promise |
| HTTP error handling | Must check xhr.status manually in onload |
Must also check response.ok manually — same caveat applies |
| Upload progress tracking | Supported via xhr.upload.onprogress |
Not natively supported (as of widespread adoption) |
| Abort support | Via xhr.abort() |
Via AbortController |
| Code readability | Verbose; prone to "callback hell" when chained | Cleaner; composes naturally with async/await |
One area where XHR historically held a genuine advantage was upload progress tracking. The xhr.upload object exposes a progress event that fires repeatedly as data is being uploaded to the server, making it possible to build accurate upload progress bars. This capability was not available in early versions of the Fetch API, which is why some file-upload libraries continued using XHR long after Fetch became mainstream.
Today, new code overwhelmingly favours the Fetch API for its cleaner syntax and natural integration with Promises. However, encountering XHR in a real project is not at all unusual. Legacy enterprise applications, third-party widgets, and older open-source libraries all contain XHR. Understanding how it works at a mechanical level — the event flow, the status-checking requirement, the manual JSON parsing — means you can read that code accurately, extend it safely, and refactor it toward modern patterns when the time is right.