JavaScript as a language is heavily asynchronous, with promises being deeply integrated. The inclusion of async/await syntax has massively improved this, making asynchronous code much more readable. Being able to mark methods as async makes it much easier to integrate into existing parts of code, sometimes causing large chains of method calls to become async for a single deep method call. While this is sometimes the best solution, can the overhead of promises pose a problem for hot code?

Benchmarks

As of writing, the v8 JavaScript engine that powers both Chromium and NodeJS does not optimise out redundant promises. Due to this, a decent benchmark to determine just the overhead of promises is to take a typical function call and make it a promise. For this case, I'm using a function that calculates a position in the Fibonacci sequence.

Test 1 - Recursive Fibonacci

The first test is using a recursive Fibonacci function. Using a recursive function as a test case here means we will get a compounding overhead from the promises. This result will show us a relatively worse case example.

The following code has been used,

// Normal case
function fibonacci(num) {
  if (num <= 1) return 1;
  return fibonacci(num - 1) + fibonacci(num - 2);
}
for (let i = 0; i < 10; i++) {
  fibonacci(i);
}
// Promise case
async function fibonacci(num) {
  if (num <= 1) return 1;
  return (await fibonacci(num - 1)) + (await fibonacci(num - 2));
}
for (let i = 0; i < 10; i++) {
  // The site does not allow top-level await calls, however it waits for all
  // promises to resolve before completing, making this relatively okay to do.
  fibonacci(i).then(() => {});
}

When running this on JSBench.Me, the promise case is shown to be 86% slower on Chrome 87 than the non-promise case.

While this is an unrealistic case, it shows that promises can significantly impact performance, especially in hot code paths.

Test 2 - Non-Recursive Fibonacci

A non-recursive Fibonacci sequence shows what sort of overhead a low-cost method call could have to get a more real-world view. In this case, each call to the function will only have the overhead of a single promise.

The following code has been used,

// Normal case
function fibonacci(num) {
  var a = 1,
    b = 0,
    temp;
  while (num >= 0) {
    temp = a;
    a = a + b;
    b = temp;
    num--;
  }
  return b;
}
for (let i = 0; i < 100; i++) {
  fibonacci(i);
}
// Promise case
async function fibonacci(num) {
  var a = 1,
    b = 0,
    temp;
  while (num >= 0) {
    temp = a;
    a = a + b;
    b = temp;
    num--;
  }
  return b;
}
for (let i = 0; i < 100; i++) {
  fibonacci(i).then(() => {});
}

When running this on JSBench.Me, the promise case is shown to be 81% slower on Chrome 87 than the non-promise case.

This result shows that adding an async modifier to the method can almost double the time it takes to execute simple functions. For hot code, this could make a significant difference.

Test 3 - Promise vs Async/Await

So we've seen what just adding the async keyword can do, but what if we use promises directly instead?

Using async code from the previous test case, and the following code,

// Promise case
function fibonacci(num) {
  return new Promise((resolve, reject) => {
    var a = 1,
      b = 0,
      temp;
    while (num >= 0) {
      temp = a;
      a = a + b;
      b = temp;
      num--;
    }
    resolve(b);
  });
}
for (let i = 0; i < 100; i++) {
  fibonacci(i).then(() => {});
}

When running this on JSBench.Me, the case using promises rather than async/await is shown to be a further 26% slower on Chrome 87 than the async/await case.

Solutions

There are a few potential solutions for this issue. Firstly, however, it's important to point out that unless you've confirmed a section of code to be causing performance issues, you don't need to optimise it. Once you've used a profiler to verify that it's worth optimising a piece of code, then it's worth investigating.

The simplest solution here is to perform data fetching or other asynchronous operations closer to the application's root and pass the resulting data down. Often program structures that involve deep-nested async/await paths exhibit poor separation of concerns. Ideally, a single system should not load and use data; instead, it should receive the data from another system that loads it. This structure also has the added benefit of being much more testable. Doing this should prevent the need to nest async/await methods deeply.

Another option is to only use the async keyword for the inner-most method if possible. Each method with an async keyword adds overhead, so if you can work with the promise directly outside of that function, you're not introducing further overhead. This technique can be even more helpful when the method only sometimes needs to return a promise. In this case, including the async keyword introduces the overhead always. If you return a promise directly, overhead (albeit slightly more overhead) happens only a portion of the time.

Conclusion

Promises and Async/Await introduce measurable overhead into JavaScript code. This problem can be worked around in most cases when necessary, yet, it will not pose any issue most of the time. As with most things, understanding the various trade-offs in performance is essential when optimising code. If you've identified a hot code path that makes heavy use of async/await, it may be worth investigating ways to minimise that.

Maddy Miller

This article was written by Maddy Miller, a software engineer at Clipchamp at Microsoft. In her spare time she works on writing articles, as well as developing the Minecraft mods WorldEdit, WorldGuard, and CraftBook. Her opinions are her own and do not reflect her employer.