What is AJAX, XMLHttpRequest, fetch(), Async and Promise?

Uci Lasmana
12 min readJan 14, 2024

--

For people who have no idea about AJAX, XMLHttpRequest, fetch(), async and Promise, confused about them, or wondering about the relation between them, I think you’ve come to the right place.

The similarities between AJAX, XMLHttpRequest, fetch(), async and promise are that they all deal with asynchronous operations in JavaScript. Asynchronous operations are operations that may take some time to complete and allow the rest of the program to continue running without waiting for those operations to finish.

AJAX (Asynchronous JavaScript and XML)

AJAX is a set of web development techniques used on the client-side to create asynchronous web applications. With AJAX, web applications can send and retrieve data from a server asynchronously (in the background), so the system doesn’t need to reload the whole page when it wants to update parts of the web page.

XMLHttpRequest and fetch() are APIs provided by browsers which are the main methods used in AJAX. AJAX is the technique, XMLHttpRequest is the old way of implementing that technique, and fetch() is the modern way of implementing it.

XMLHttpRequest

XMLHttpRequest is a built-in browser object in JavaScript that allows you to make HTTP requests. Despite having the word “XML” in its name, it can operate on any data, not just in XML format. It allows a web page to make requests to a server and receive responses without requiring a page refresh.

XMLHttpRequest is a callback-based API which means XMLHttpRequest uses a function known as a callback to handle the asynchronous operation. This callback function will be called when the request’s state changes, typically after the server’s response is received.

Here’s an example of how XMLHttpRequest works with a callback:

function loadData(url, callback) {
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function() {
if (xhr.readyState === XMLHttpRequest.DONE) {
if (xhr.status === 200) {
// Call the callback function with the response text
callback(null, xhr.responseText);
} else {
// Call the callback function with the error
callback(xhr.status);
}
}
};
xhr.open('GET', url, true);
xhr.send();
}
// Usage
loadData('http://example.com/api/data', function(error, data) {
if (error) {
console.error('Error:', error);
} else {
console.log('Data:', data);
}
});

Here’s a breakdown of what the code is doing:

  • In this example, loadData is a function that makes an HTTP GET request to the specified URL. It takes a callback function as a parameter, which is called when the request is completed.
  • The callback function is passed the result of the request. If successful, the response text is provided, but if there’s an error, the status code is provided.
  • The onreadystatechange event listener is used to check the state of the request and respond accordingly when it’s done.
  • The readyState property represents the state of the request, start from 0 to 4. 0 state is when the request not initialized, 1 state is when the server connection established, 2 state is when the request received, 3 state is when processing the request, 4 state is when the request finished and response is ready, whether it’s success or fail.
  • While the request is being processed, other code that comes after the loadData will be execute. This is the essence of asynchronous programming.
  • When the readyState property is 4 and the status property is 200, the response is ready. The responseText property returns the server response as a text string, which can be used to update a web page.

Promise

A Promise is an object that represents the eventual completion (or failure) of an asynchronous operation and its resulting value. A Promise is like a placeholder for the eventual outcome of the operation. Once the network request is complete, this Promise is updated with a Response object if the request was successful. If the request failed, the Promise is updated with an error.

A Promise is always in one of these states:

  • Pending: The initial state, neither fulfilled nor rejected.
  • Fulfilled: The operation completed successfully.
  • Rejected: The operation failed.

Promises have several methods to handle their completion or failure. Here are some of the most commonly used Promise methods:

  • then() is used to specify what should happen when the Promise is fulfilled which is when the request is success. This method used for handling successful operations.
  • catch() is used to specify what should happen when the Promise is rejected which is when the request is fail. This method used for error handling.
  • finally() is used for performing actions after the Promise has been settled. A promise is said to be settled if it is either fulfilled or rejected, but not pending.

What if there is no catch() method?

If a Promise is rejected and there is no catch() method present to handle the rejection, JavaScript will generate an ‘unhandled promise rejection’ warning. This warning signifies that an error occurred within the Promise, but there was no error handler at the end of the Promise chain to manage it. The specifics of the ‘unhandled promise rejection’ warning can vary based on the JavaScript environment, but it typically includes the reason for the Promise’s rejection.

In some environments, such as Node.js, unhandled promise rejections can cause the application to terminate. It’s considered good practice to always include a catch() method at the end of a Promise chain to handle any errors that might occur, even if we don’t expect any to happen. This approach helps prevent unexpected crashes and facilitates easier debugging if an error does occur.

What if I want to handle the error in the then() method?

We can certainly handle errors within the then() method itself. The then() method actually takes two arguments:

  • The first is a callback for a fulfilled promise (success case)
  • The second is a callback for a rejected promise (error case).

Here’s the example:

let promise = new Promise((resolve, reject) => {
let condition = true; // This can be any condition

if(condition) {
resolve("Promise is resolved!");
} else {
reject("Promise is rejected!");
}
});

promise.then(
successMessage => console.log(successMessage), // This will run if the Promise is resolved
errorMessage => console.log(errorMessage) // This will run if the Promise is rejected
);

In this example, we use Promise constructor to create a new promise object that takes two parameters, resolve and reject, which are both functions.

We don’t always have to use resolve and reject as the names of the callback functions in a Promise.

The important thing is the order of the parameters, the first function is called when the Promise is resolved, and the second function is called when the Promise is rejected.

  • If the condition is true, the Promise is resolved and the resolve function is called with the message ‘Promise is resolved!.

‘Resolve’ means that the Promise has been ‘fulfilled.’ It indicates that the operation completed successfully, and the Promise has returned the expected value.

  • If the condition is false, the Promise is rejected and the reject function is called with the message ‘Promise is rejected!.
  • The then method is called when the Promise is either resolved or rejected.

Just like the Promise, order of parameters in then() method is important too.

  • The first function passed to then is called when the Promise is resolved, and the second function is called when the Promise is rejected.
  • Both functions take a message as a parameter, which is the message from the resolve or reject function. In this case, the message is logged to the console.

However, it’s important to note that if an error is thrown in the success callback itself, such as the response is not valid JSON, trying to access a property or call a method on undefined or null, trying to use a variable that has not been declared, it won’t be caught by the error callback in the same then() method. For this reason, it’s often recommended to use separate catch() blocks to handle errors, as they can catch both errors thrown in the Promise and errors thrown in success callbacks.

promise.then(result=>{
// This will run if the Promise is resolved
console.log(result);
}).catch(error=>{
// This will run if the Promise is rejected
console.log(error);
});

Async

An async function is a function that returns a new promise every time it gets called. The purpose of async is to simplify the syntax when you work with promises.

This is how you define the async function:

// Define the async function
async function theFunction() {
// Asynchronous code here
return "This is async";
}

An async function is defined using the async keyword before the function name. If the function explicitly returns a value, that value is automatically wrapped in a resolved Promise. The code provided above give the same result as the following code:

function theFunction() {
return Promise.resolve("This is async");
}

This is how you call the async function:

theFunction().then(result => {
console.log("Result: ", result);
// Log "Result: This is async" to the console
}).catch(error => {
console.error("Error:", error.message);
// Log the error to the console
});

Async functions can contain zero or more await expressions. The await keyword can only be used inside async functions. await lets you pause an execution until the promise settles (resolves or rejects).

The purpose of using await is to ensure that we wait for the data to be fetched before proceeding, making the code more predictable and easier to follow. If we don’t use await, the app might try to display the information before it’s actually available. This could result in empty or incorrect data being shown to the user.

This is how we used the async function with await:

async function theFunction() {
try {
const result= await new Promise(resolve =>{
setTimeout(() => resolve("This is async"), 1000)
});
// Show the alert after waiting for 1 second
alert(result);
} catch (error) {
alert("Error:", error.message);
}
}

// Call the async function
theFunction();

Notes:

  • In the code above we used try/catch to handle errors that might occur during the execution of asynchronous code.
  • The code inside the try block is executed, and when the error occurs, the control is passed to the catch block, where the error can be handled.
  • The await keyword only returns the resolved value of a Promise. If the Promise is rejected, await throws an exception. If this exception isn’t caught by a try/catch block, it becomes an unhandled promise rejection.
  • Using try/catch allows you to handle both fulfilled and rejected Promises in a clean, readable way.

Always use await with asynchronous operations (like fetching data, reading files, or making API calls). await is essential for managing asynchronous tasks effectively, ensuring that your code behaves as expected and provides a better user experience!

fetch()

The Fetch API provides a JavaScript interface for accessing and manipulating parts of the protocol, such as requests and responses. It provides a global fetch() method that provides an easy, logical way to fetch resources asynchronously across the network. It is a more powerful and flexible replacement for XMLHttpRequest.

Unlike XMLHttpRequest, which is a callback-based API, fetch() is promise-based. The fetch() method returns a Promise object that represents the response to the request, whether it is successful or not.

Here’s an example of how you can use fetch():

  1. Using fetch() with .then() and .catch() methods:
function fetchData(url) {
fetch(url)
.then(response => {
// Check if the request was successful
if (!response.ok) {
throw new Error('Network response was not ok ' + response.statusText);
}
// Use the .json() method to parse the response body as JSON
return response.json();
})
.then(data => {
// Handle the parsed data
console.log(data);
})
.catch(error => {
// Handle any errors that occurred during the fetch
console.error('There has been a problem with your fetch operation:', error);
});
}
// Usage
fetchData('http://example.com/data.json');

Here’s a breakdown of what the code above is doing:

  • We use fetchData() to make a network request to a specified URL, ‘https://api.example.com/data’. In this function we use fetch() to retrieve data from the URL.
  • The fetch() method returns a Promise that resolves to a Response object representing the response of the request.
  • then() is a method that handles the fulfilled promise from the fetch() call. Inside this method, we check if the response was successful.
  • If the response is successful, it calls response.json() to parse the response body as JSON, then executes the second then() method which receives the parsed JSON data from the previous then(). Here, we log the data to the console with console.log(data).

If a Promise is rejected, the execution will skip any remaining then() methods and go straight to the catch() method.

  • However if the response is fail, it throws an error with the message ‘Network response was not ok’ along with the status text of the response. When this happens, the code execution will indeed skip any remaining code in the current then(), then go straight to the catch() method.
  • The catch() method catches any errors that occur during the fetch() operation. It logs the error message to the console with console.error().

2. Using fetch() with async/await function:

async function fetchData(url) {
try{
const response = await fetch(url);
const data = await response.json();
console.log(data);
}
catch (error){
console.error('There has been a problem with your fetch operation:', error);
}
fetchData('http://example.com/data.json');

Here’s a breakdown of what the code above is doing:

  • We have fetchData() as an async function here. In this function we used try/catch to handle errors that might occur during the execution.
  • Inside the try block, we make a network request to a specified URL, ‘https://api.example.com/data’ by using the fetch() function to retrieve data from the URL.
  • We use await when we fetch() the data, this means we pause execution of the function until the promise resolves. This ensures that the data is available before we proceed to the next line (const data = await response.json())
  • If the Promise is rejected, await throws an exception. The catch block will handle the errors.

Both ways achieve the same result, only the way they handle asynchronous operations is different. The async/await syntax can be easier to read and understand, especially for avoiding nested callbacks or .then() chains.

XMLHttpRequest vs fetch()

Here are a few differences between XMLHttpRequest and fetch():

Callback Hell

With fetch(), you can chain .then() methods to process the response and catch() to handle any errors. Because fetch is promised based, unlike XMLHttpRequest which is a callback-based API, with fetch() we can avoid the “callback hell”.

When we have multiple asynchronous operations that need to happen in a specific order, managing them can become complex. This complexity is often referred to as “callback hell” because you end up with deeply nested callbacks, which can make the code hard to read and understand.

Here’s an example of what callback hell might look like with XMLHttpRequest:

//First Request
const request1 = new XMLHttpRequest();
request1.open('GET', 'https://api.example.com/data1');
request1.onload = () => {
const data1 = JSON.parse(request1.responseText);
console.log(data1);
//Second Request inside the first request
const request2 = new XMLHttpRequest();
request2.open('GET', 'https://api.example.com/data2');
request2.onload = () => {
const data2 = JSON.parse(request2.responseText);
console.log(data2);
//Third Request inside the second request
const request3 = new XMLHttpRequest();
request3.open('GET', 'https://api.example.com/data3');
request3.onload = () => {
const data3 = JSON.parse(request3.responseText);
console.log(data3);
};
request3.send();
};
request2.send();
};
request1.send();

In this example, each subsequent request depends on the completion of the previous one, leading to multiple levels of nested callbacks.

And here’s how we could do the same thing with fetch:

async function fetchData() {
try {
// First Request
let response = await fetch('https://api.example.com/data1');
let data1 = await response.json();
console.log(data1);

// Second Request
response = await fetch('https://api.example.com/data2');
let data2 = await response.json();
console.log(data2);

// Third Request
response = await fetch('https://api.example.com/data3');
let data3 = await response.json();
console.log(data3);
} catch (error) {
console.error('Error:', error);
}
}

// Usage
fetchData();

In both examples, three network requests are made sequentially, with each request being dependent on the completion of the previous one.

Code Structure

The fetch() method is often considered easier to use compared to XMLHttpRequest. fetch() provides a more straightforward and cleaner reading experience due to promise chaining, which simplifies working with multiple dependent asynchronous operations.

When we use the fetch method, we can utilize promise chaining. This is a technique where the result of one promise is used to initiate another promise. This technique enables us to create a sequence of asynchronous operations that are executed sequentially. Each promise in the chain returns a new promise, allowing the operations to be linked together.

Error Handling

The fetch() method is only rejects a promise when a network error is encountered, such as when the user is offline or the requested URL does not exist. Importantly, fetch() does not reject the Promise for HTTP error statuses (like 404 or 500). Instead, it resolves the Promise with the Response object, and you would need to check the ok property of the Response object to determine if the request was successful.

If we do not check the ok property, the execution will continue and the HTTP error will not be caught and handled in the catch() method.

Meanwhile, XMLHttpRequest triggers an error when the request fails for any reason, including both network errors and HTTP error statuses. This means that if you make a request to a URL that returns a 404 status, XMLHttpRequest will trigger an error, and the execution of the request will stop immediately.

There are many things to discover and learn about these topics, but I hope this article can provide you with basic knowledge and help you understand the relationship between them.

If you want to learn about APIs, I write an article, “A Guide to APIs (Collection of Questions and Answers for Beginners)”. This article is a collection of the questions and answers that came up when I first learned about APIs.

Have a good day!

--

--

Uci Lasmana

Sharing knowledge not only helps me grow as a developer but also contributes to the growth of fellow developers. Let’s grow together!