Promises are extremely powerful for handling asynchronous operations in JavaScript. Async functions make them easier to read and reason about. However, they also introduce some sneaky traps that can lead to slowdowns if we’re not careful.

tl;dr

To make sure your async code isn’t slowing down your apps, check your code to ensure that:

  • If you’re calling async functions with await, don’t let unrelated async calls block each other.
  • Don’t use await inside loops. Create an array of Promises and await Promise.all instead.

This article will walk through a few examples and how they can be refactored to avoid blocking execution when using await.

Promises are a wonderful, powerful tool.

When sending off requests to load third-party data or do other asynchronous work, using a Promise has become a common pattern for telling our code to wait until the async work is done before continuing.

We can see this in action by writing a function that simulates a slow network connection: when the function is called, it will wait 1 second before resolving (that’s Promise-jargon for “the asynchronous work is complete”).

// -------------------------------------------------------------------
// this function simulates a request that needs to run async
// -------------------------------------------------------------------
function getFakeData() {
return new Promise((resolve) => {
setTimeout(() => {
resolve({ action: 'boop' });
}, 1000);
});
}
// -------------------------------------------------------------------
// this is the code that actually loads data
// -------------------------------------------------------------------
getFakeData().then((response) => {
console.log(response.action);
//=> boop
});

This is a code-along article!

All the code samples in this article can be run in your console. To see these in action, open up developer tools (command + option + I), copy paste the code sample into the console, and press enter.

All of the code examples in this post can be run in your browser’s devtools!

If you don’t want to use your console, you can also use CodePen or create a JavaScript file in your favorite editor to run in your browser.

Promises power many of our data fetching workflows.

One of the most common ways we work with Promises is when loading data with the Fetch API or a library like Axios. For example, if we want to load a random dog image from Dog CEO, our code might use the Fetch API (and Promises) like this:

function getData() {
// the Fetch API returns a Promise
fetch('https://dog.ceo/api/breeds/image/random')
.then((response) => {
// `.then()` is called after the request is complete
// this is part of the Fetch API for handling JSON-encoded responses
return response.json();
})
.then((response) => {
// We can do whatever we want with the data now!
console.log(response);
});
}
getData();

Fetch sends off a request to the Dog CEO REST API and waits for a response. Once the response comes back, it resolves a Promise with the data that came back. It provides helpers for handling different response types (JSON in this case), which are called in a .then, and after that point we can do whatever we want with the data by chaining additional .then calls.

Promises don’t return values for use outside themselves.

When I started learning Promises, I struggled to wrap my head around the idea that there’s no straightforward way to get the data out of a Promise; you have to work inside the .then once you’ve started using them:

// -------------------------------------------------------------------
// this function simulates a request that needs to run async
// -------------------------------------------------------------------
function favoritePupper(favorite) {
return new Promise((resolve, reject) => {
if (favorite === 'corgi') {
resolve(favorite);
} else {
reject('your preference is incorrect');
}
});
}
// -------------------------------------------------------------------
// this is the code that actually loads data
// -------------------------------------------------------------------
// 🚫 this doesn’t work
function getFavoritePupperBroken() {
// the return value is a Promise, not the value that resolved the Promise
const pupper = favoritePupper('corgi');
// that means we can’t return the value from this function 😱
console.log(`My favorite pupper is a ${pupper}.`);
//=> My favorite pupper is a [object Promise].
}
// ✅ this works
function getFavoritePupper() {
const pupper = favoritePupper('corgi');
// we can only get to the value using the `.then` chain
pupper.then((doggo) => {
console.log(`My favorite pupper is a ${doggo}.`);
//=> My favorite pupper is a corgi.
});
}
getFavoritePupper();

The reasoning makes sense: Promises are asynchronous, but they shouldn’t block synchronous code that doesn’t depend on them, so the asynchronous code has to be isolated.

However, trying to “think in Promises” has caused me a lot of headaches. It always takes me a minute to get my brain into the right mode to write Promise-based code.

Nested Promises are hard to keep track of.

If our content has multiple async steps, the nesting in Promises can become challenging to keep track of:

// -------------------------------------------------------------------
// this function simulates a request that needs to run async
// -------------------------------------------------------------------
function doOtherAsyncThing() {
return new Promise((resolve) => {
setTimeout(() => resolve('it’s done!'), 500);
});
}
// -------------------------------------------------------------------
// this is the code that actually loads data
// -------------------------------------------------------------------
function getData() {
fetch('https://dog.ceo/api/breeds/image/random')
.then((response) => response.json())
.then((response) => {
doOtherAsyncThing().then((otherResponse) => {
// do stuff with `response` and `otherResponse`
console.log({ response, otherResponse });
});
});
}
getData();

Because next steps have to happen inside a .then(), each async action further nests our code, which adds cognitive overhead and can make code hard to refactor and maintain as time goes on.

Async functions make Promises easier to use…

To make Promises easier to work with, async functions introduce the async and await keywords that allow us to get the benefits of Promises — waiting for an async all to complete before continuing — without the mental overhead of chaining .then calls and nesting Promises.

Let‘s refactor the code we’ve written so far in this article using async functions to make it a little easier to read:

async function getData() {
// using await means the resolved value of the Promise is returned!
const response = await fetch('https://dog.ceo/api/breeds/image/random').then(
(response) => response.json()
); // .then still works when it makes sense!
const otherResponse = await doOtherAsyncThing();
// do stuff with `response` and `otherResponse`
console.log({ response, otherResponse });
}
getData();
async function getFavoritePupper() {
const pupper = await favoritePupper('corgi');
console.log(`My favorite pupper is a ${pupper}.`);
//=> My favorite pupper is a corgi.
}
getFavoritePupper();

The await tells our code to wait for the Promise to resolve, then hands back the resolved value of the Promise as the return value. This removes a lot of boilerplate associated with Promises, and that’s a Good Thing™.

…but async functions also introduce new challenges.

Unfortunately, the convenience of async functions comes with some traps that aren’t immediately obvious.

Let’s take a look at two examples:

  1. Two unrelated async operations that load data, then do separate things.
  2. An async chain: one operation that loads an array, then a series of calls that depend on the result of the original call.

Two unrelated async operations

With Promises, we might set this up like so:

// -------------------------------------------------------------------
// these functions simulate requests that need to run async
// -------------------------------------------------------------------
function asyncThing1() {
return new Promise((resolve) => {
setTimeout(() => resolve('Thing 1 is done!'), 2000);
});
}
function asyncThing2() {
return new Promise((resolve) => {
setTimeout(() => resolve('Thing 2 is done!'), 2000);
});
}
// -------------------------------------------------------------------
// this is the code that actually loads data
// -------------------------------------------------------------------
function doThings() {
asyncThing1().then((thing1) => {
// do something with the first response
console.log(thing1);
});
asyncThing2().then((thing2) => {
// do something with the second response
console.log(thing2);
});
}
doThings();

If you run this in your console, you’ll see that Thing 1 and Thing 2 complete at nearly the same time.

If we refactor to use async functions, our code might look like this:

// 🚫 don’t do this in your real code; it’s slow!
async function doThings() {
const thing1 = await asyncThing1();
console.log(thing1);
const thing2 = await asyncThing2();
console.log(thing2);
}
doThings();

This looks nice and clean, but if we run it in our console we’ll notice an issue: the code now takes twice as long to run! 😱

Promises run in parallel because they don’t care what happens outside of them — that‘s why we can only access their content inside a .then. This means that both asyncThing1() and asyncThing2() run at the same time in our first example.

In async functions, await blocks any code that follows from executing until the Promise has resolves, which means that our refactored code doesn’t even start asyncThing2() until asyncThing1() has completed — that’s not good.

There’s good news, though: we can fix this without giving up on the benefits of async functions!

// ✅ do this — async code is run in parallel!
async function doThings() {
const p1 = asyncThing1();
const p2 = asyncThing2();
const [thing1, thing2] = await Promise.all([p1, p2]);
console.log(thing1);
console.log(thing2);
}
doThings();

Because async functions are Promises under the hood, we can run both asyncThing1() and asyncThing2() in parallel by calling them without await. Then we can use await and Promise.all, which returns an array of results once all Promises have completed.

This allows the Promises to run in parallel again, but still gives us a pleasant-to-use syntax that avoids chaining and lets us treat the values in our Promises as standard return values.

Async operations with dependencies

In some cases, we’ll have async operations that depend on the results of previous async operations. For example, if we want to load a list of blog posts from one API endpoint, then load comment data for each blog post from another.

With Promises, that setup might look like this:

// -------------------------------------------------------------------
// these functions simulate network requests to load data from an API
// -------------------------------------------------------------------
function getBlogPosts() {
const posts = [
{ id: 1, title: 'Post One', body: 'A blog post!' },
{ id: 2, title: 'Post Two', body: 'Another blog post!' },
{ id: 3, title: 'Post Three', body: 'A third blog post!' },
];
return new Promise((resolve) => {
setTimeout(() => resolve(posts), 200);
});
}
function getBlogComments(postId) {
const comments = [
{ postId: 1, comment: 'Great post!' },
{ postId: 2, comment: 'I like it.' },
{ postId: 1, comment: 'You make interesting points.' },
{ postId: 3, comment: 'Needs more corgis.' },
{ postId: 2, comment: 'Nice work!' },
];
// get comments for the given post
const postComments = comments.filter((comment) => comment.postId === postId);
return new Promise((resolve) => {
setTimeout(() => resolve(postComments), 300);
});
}
// -------------------------------------------------------------------
// this is the code that actually loads data
// -------------------------------------------------------------------
function loadContent() {
getBlogPosts().then((posts) => {
for (const post of posts) {
getBlogComments(post.id).then((comments) => {
console.log({ ...post, comments });
});
}
});
}
loadContent();

This code is perfectly fine. However, the nesting is pretty deep here, and it’s a little challenging to track where things are happening — we’ve got callbacks in loops in callbacks.

To clean this up, our first instinct might be to refactor this code to use async functions:

async function loadContent() {
const posts = await getBlogPosts();
for (post of posts) {
// using await here means the Promise has to resolve before the loop continue
const comments = await getBlogComments(post.id);
console.log({ ...post, comments });
}
}
loadContent();

So much cleaner! 😍

Unfortunately, each request for comments now has to wait for the one before it to complete, meaning our code is now significantly slower — and it’ll only get worse as we add more posts!

To clean this up, we can use .map instead of a for loop and create an array of Promises, then use Promise.all to wait for them to complete.

async function loadContent() {
const posts = await getBlogPosts();
// instead of awaiting this call, create an array of Promises
const promises = posts.map((post) => {
return getBlogComments(post.id).then((comments) => {
return { ...post, comments };
});
});
// use await on Promise.all so the Promises execute in parallel
const postsWithComments = await Promise.all(promises);
console.log(postsWithComments);
}
loadContent();

This code is still much more readable than the nested Promises and for loops, and it allows the requests to load comments to happen in parallel.

Keep async functions fast!

This is just one optimization to keep in mind when writing asynchronous code. Tuck it into your toolkit and keep your async functions fast!

Have other ideas for speeding up async functions? Hit me up on Twitter!