Promises, Async, and Await in JavaScript

You’re building the front-end of the next big social media app and you need to make some API calls to display posts. The app needs to grab a user's info in the first call, grab their posts in a second call, and then grab all of the comments off the posts in a third call.
In the early days of JavaScript, doing anything asynchronous – like making API calls – was done by using a callback to handle the data once it arrived. We’ll use XMLHttpRequest, the JavaScript class used to make API calls in the browser before fetch, to make our first API call and get the user’s info.
// Create a new XMLHttpRequest object
var xhr = new XMLHttpRequest();
// Configure the request
xhr.open('GET', 'https://api.com/username', true /* set async to true */);
// onload takes our callback function
xhr.onload = function() {
if (xhr.status === 200) {
// Parse the JSON response
var userInfo = JSON.parse(xhr.responseText);
console.log(userInfo)
}
};
// Send the API request
xhr.send();
Alright, now let's make our second and third call to get the user’s posts and then get the comments off of the posts. Since there’s a lot of boilerplate in making calls with XMLHttpRequest, we’ll put the boilerplate in a helper function and clean the code up.
// Start making API calls. Uh oh,Callback Hell
makeRequest('https://api.com/username', function(user) {
console.log(user);
var postsUrl = 'https://api.com/posts/' + user.post.id
// Make request for posts off user
makeRequest(postsUrl, function(post) {
console.log(post);
var commentsUrl = 'https://api.com/comments' + post.comment.id;
// Make request for comments off posts
makeRequest(commentsUrl, function(comments) {
console.log(comments);
}, function(commentError) {
console.error(commentError);
});
}, function(postError) {
console.error(postError);
});
}, function(userError) {
console.error(userError);
});
// xhr helper function
function makeRequest(url, onSuccess, onError) {
var xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
xhr.onload = function() {
if (xhr.status === 200) {
var data = JSON.parse(xhr.responseText);
onSuccess(data);
} else {
onError('Error occurred while fetching data: ' + xhr.status);
}
};
xhr.onerror = function() {
onError('Request failed');
};
xhr.send();
}
We did it! We’re making all the API calls we need to make.
But this code’s looking pretty rough, huh? Everything is nested and hard to read. It’ll be hard for us to manage this code, but even worse, what if someone else joins the team once we’re a super successful startup? Other people are going to have an even harder time maintaining this code since they didn’t write it themselves.
The code above is an example of The Pyramid of Doom - more commonly known as Callback Hell. This is not a pattern you want to see in your code. It’s frustrating to read and hard to maintain. Even if the code works, it can easily come back and bite you in the behind if you aren’t careful.
So what can be done to avoid Callback Hell?
Introducing Promises
Promises in JavaScript are asynchronous objects that will either eventually resolve with data or throw an exception. They were introduced in ES6 to help make asynchronous tasks in JavaScript easier to write, and to avoid problems like Callback Hell. I bet you’ve probably used fetch more often than XMLHttpRequest, so you might already be familiar with promises - as fetch returns a promise.
When using promises, we can interact with the data returned by our API calls by using the then method, and we can chain them together into a Promise Chain. This allows us to prevent putting callbacks inside of callbacks.
Promises also have a catch method for handling errors, and we can chain that method to the end of our calls to catch any errors that happen in the chain. We can also add the `finally` method at the end of the chain to run some code regardless of whether or not we run into errors in our chain, similar to having code outside of your try/catch block in a function. Now, instead of running into Callback Hell, we can write code like this:
fetch('https://api.com/username')
.then(response => response.json())
.then(user => {
const postsUrl = 'https://api.com/posts/' + user.post.id
return fetch(postsUrl);
})
.then(response => response.json())
.then(post => {
const commentsUrl = 'https://api.com/comments/' + post.comment.id;
return fetch(commentsUrl);
})
.then(response => response.json())
.then(comments => {
console.log(comments);
})
.catch(error => {
console.error('Error:', error);
});
.finally(() => {
console.log('complete');
});
Way better! But promises have a couple more surprises for us. We’ve currently been making API calls one at a time, but what if we wanted to make multiple calls at once? To do that, we could use Promise’s all method, which takes an array of promises and processes them at the same time. This makes pulling user data much faster.
const userData = 'https://api.com/username';
const userPosts = 'https://api.com/posts/username';
Promise.all([fetch(userData), fetch(userPosts)])
.then(responses => {
return Promise.all([
responses[0].json(),
responses[1].json()
]);
})
.then(data => {
// userData
console.log(data[0]);
// userPosts
console.log(data[1]);
})
.catch(error => {
console.error('Error:', error);
});
You can also make your own promises for handling asynchronous operations. When making a promise, you feed it a callback that takes a resolve and reject function. When the promise resolves, it will return some data. And if it rejects, it will throw an error that gets caught by our catch method.
Let’s make a promise and turn it into a little game. We'll use a simple asynchronous operation - setTimeout - for our example. The user has 5 seconds to click a button on the screen. If they click it, the promise will resolve. And if they don’t, the promise will be rejected.
let buttonClicked = false;
const button = document.querySelector('#my-button');
button.addEventListener('click', () => {
buttonClicked = true;
});
const promise = new Promise((resolve, reject) => {
setTimeout(() => {
if (buttonClicked) {
resolve('You won! You clicked the button in time');
} else {
reject('You lose :(');
}
}, 5000);
});
promise
// You won! You clicked the button in time
.then(value => console.log(value))
// You lose :(
.catch(error => console.error(error));
Pretty fun, huh? Maybe we should just have that be our app instead :p.
Now let’s say we want to let our app users upload pictures. We want a way for our users to cancel uploading if it takes too long. We can do that using the race method, which takes 2 promises and cancels one promise when the other completes. We’ll make a promise that resolves when a button is pressed and race it against the upload call so that clicking it cancels the upload.
const controller = new AbortController();
const { signal } = controller;
const uploadPromise = fetch('https://api.com/upload', {
method: 'POST',
body: pictureData,
signal
}).then(response => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}).then(data => {
console.log('Upload successful:', data);
});
const button = document.querySelector('#cancel-button');
const cancelPromise = new Promise(resolve => {
button.addEventListener('click', resolve, { once: true });
});
Promise.race([uploadPromise, cancelPromise])
.then(() => {
controller.abort();
console.log('Upload cancelled');
})
.catch(error => {
console.error('Error:', error);
});
Now users won’t be stuck staring at a loading screen if their upload takes too long.
We’ve got some great new ways to handle asynchronous operations now. Our app is so well built that we’re gonna have VC’s throwing money at us in no time!
But you may have noticed something. Although we’ve avoided nesting callbacks inside of callbacks, we’re still stuck with a pretty long Promise Chain with a lot of our operations. Isn’t there a way to make our asynchronous calls read like regular JavaScript?
Async Functions and the Await Keyword
That’s where the async and await keywords come in. async and await are syntactical sugar added to ES7 for promises that make our code write like synchronous JavaScript. The await keyword gives you the value of a promise without making you use the then method to interact with it, and the async keyword allows for using the await keyword inside of its scope and makes our function return a promise by default. Check out what it looks like to make that original user page call using async and await.
async function fetchUserPostsAndComments() {
try {
let response = await fetch('https://api.com/username');
let user = await response.json();
console.log(user);
// Make API call for Posts
response = await fetch('https://api.com/posts/' + user.post.id);
let post = await response.json();
console.log(post);
const commentsUrl = 'https://api.com/posts/comments/' + post.comment.id;
// Make API call for Comments
response = await fetch(commentsUrl);
let comments = await response.json();
console.log(comments);
} catch (error) {
console.error('Error:', error);
}
console.log('complete');
}
fetchUserPostsAndComments();
Finally! Asynchronous calls that read like regular JavaScript. There’s no doubt that this is cleaner than Callback Hell or making a long Promise Chain.
Although you know how I said await is just syntactical sugar? Well, I kind of lied. Because there is a key difference between using then and await. While using then off of a fetch request, JavaScript will continue to execute code further down the code block while waiting to get data back. However, with await, JavaScript will actually pause the code block on that line, which can cause some execution differences between the two.
Also, async/await doesn’t completely remove the need to use promise methods. There’s no way to replicate all or race with async/await. But luckily, we can mix using async and promise methods inside of an async code block. Check out what our all and race methods look like being used inside of an async code block.
All:
const userData = 'https://api.com/username';
const userPosts = 'https://api.com/posts/username';
async function fetchData() {
try {
const responses = await Promise.all([fetch(userData), fetch(userPosts)]);
const data = await Promise.all([responses[0].json(), responses[1].json()]);
// userData
console.log(data[0]);
// userPosts
console.log(data[1]);
} catch (error) {
console.error('Error:', error);
}
}
fetchData();
Race:
const controller = new AbortController();
const { signal } = controller;
const button = document.querySelector('#cancel-button'); // Replace with your actual button selector
button.addEventListener('click', () => controller.abort(), { once: true });
async function uploadPicture() {
try {
const response = await fetch('https://api.com/upload', {
method: 'POST',
body: pictureData, // Replace with your actual picture data
signal
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
console.log('Upload successful:', data);
} catch (error) {
if (error.name === 'AbortError') {
console.log('Upload cancelled');
} else {
console.error('Error:', error);
}
}
}
// Start the picture upload and race it against the cancel button click
const uploadPromise = uploadPicture();
const cancelPromise = new Promise(resolve => {
button.addEventListener('click', resolve, { once: true });
});
Promise.race([uploadPromise, cancelPromise])
.catch(error => {
console.error('Error:', error);
});
We can see that our async examples don’t look much different than our original examples. There’s times where it can be simpler to use async/await, times where it isn’t, and times where it’s best to mix them. As with everything in programming, it's all about using the best tools for your situation.
Wrapping up
We made a super awesome app that can withstand the test of time. We avoided Callback Hell and removed all the long Promise Chains, making our code easy to read and maintain for the long haul. And by mixing promise methods inside of async code blocks (when we need to), we can make robust operations that feel like synchronous JavaScript.