A image that illustrates the post content, normally it don't have meaningful content.
~6 min read.

How return await can slow down your code

Awaiting a promise before returning it slows down your code.

TL;DR: Enable the no-return-await ESLint rule.

A major paradigm of large-scale application development is choosing to code in high-level languages. They speed up development stages but with a tradeoff to be slower at runtime. Which, in our case, Javascript was chosen.

You might be thinking: “If I really need performance, I should be coding in Rust”, for example. But, that’s not how it literally works. NodeJs is fast enough for almost everything, from development to runtime speeds. The Techempower benchmark also shows that it can handle very good results.

If you could improve just one percent of your code, for example in a web API, it would be delivering 800 more requests if your server handles around 8K concurrent requests per second.

And that brings us to the today’s percent:

Awaiting promises before returning.

A very common pattern in Asynchronous Javascript Programming is to code functions that waits and wraps many other async function calls:

1async function example() {
2 const server = await createServer();
3 const endpoints = await createEndpoints(server);
4
5 return await startServer(server, endpoints);
6}

As you can see, the above function has an await before a return statement. And that’s exactly where it gets tricky.

Before explaining the problem in depth, let’s build a simple benchmark to show how this gets slower:

The benchmark

It’s good to you know that every code used here is hosted at the arthur-place/the-cost-of-return-await github repository.

There is a main work() function. It simply returns a promise which is resolved as soon as the setImmediate function is called.

And there are also these 3 other functions. They produce exactly the same result, but one is asynchronous and uses await, the other is just asynchronous and the last one only uses a raw return statement.

1// function work(): Promise<any>;
2
3async function doWait() {
4 return await work();
5}
6
7async function dontWait() {
8 return work();
9}
10
11function justReturn() {
12 return work();
13}

In a real world scenario, the javascript is transpiled to a much more older EcmaScript version, that’s exactly what we’re doing.

Build targets and generated code

I made a simple test and transpiled the same source file from ES3 to ES2022, all the outputs that were different are shown below:

Babel (Regenerator Runtime)
1function doWait() {
2 return _doWait.apply(this, arguments);
3}
4
5function _doWait() {
6 _doWait = (0, _asyncToGenerator2['default'])(
7 /*#__PURE__*/ _regenerator['default'].mark(function _callee() {
8 return _regenerator['default'].wrap(function _callee$(_context) {
9 while (1) {
10 switch ((_context.prev = _context.next)) {
11 case 0:
12 _context.next = 2;
13 return work();
14
15 case 2:
16 return _context.abrupt('return', _context.sent);
17
18 case 3:
19 case 'end':
20 return _context.stop();
21 }
22 }
23 }, _callee);
24 })
25 );
26 return _doWait.apply(this, arguments);
27}
28
29function dontWait() {
30 return _dontWait.apply(this, arguments);
31}
32
33function _dontWait() {
34 _dontWait = (0, _asyncToGenerator2['default'])(
35 /*#__PURE__*/ _regenerator['default'].mark(function _callee2() {
36 return _regenerator['default'].wrap(function _callee2$(_context2) {
37 while (1) {
38 switch ((_context2.prev = _context2.next)) {
39 case 0:
40 return _context2.abrupt('return', work());
41
42 case 1:
43 case 'end':
44 return _context2.stop();
45 }
46 }
47 }, _callee2);
48 })
49 );
50 return _dontWait.apply(this, arguments);
51}
ES5 (Tslib Awaiter and Tslib Generator)
1function doWait() {
2 return __awaiter(this, void 0, void 0, function () {
3 return __generator(this, function (_a) {
4 switch (_a.label) {
5 case 0:
6 return [4 /*yield*/, work()];
7 case 1:
8 return [2 /*return*/, _a.sent()];
9 }
10 });
11 });
12}
13
14function dontWait() {
15 return __awaiter(this, void 0, void 0, function () {
16 return __generator(this, function (_a) {
17 return [2 /*return*/, work()];
18 });
19 });
20}
ES6 (Tslib Awaiter)
1function doWait() {
2 return __awaiter(this, void 0, void 0, function* () {
3 return yield work();
4 });
5}
6
7function dontWait() {
8 return __awaiter(this, void 0, void 0, function* () {
9 return work();
10 });
11}
ES2017 (Native)
1async function doWait() {
2 return await work();
3}
4async function dontWait() {
5 return work();
6}

And that’s how I chose the necessary targets.

It is also important to show the configuration of my computer:

  • Node v16.14.0
  • Nvm 0.39.1
  • i5 9600K @ 5GHz OC
  • 32GB @ 3200Mhz
  • 1TB SSD PCIe 4.0

Benchmark Results

You are probably also wondering, why was Es6 considerably slower?

My simple research showed that the tslib generator polyfill (__generator) is faster than the Node 17 native function* and yield Node 17 implementation. But that’s a story for another day.

What’s wrong with return await?

The Babel, ES5 and ES6 targets are expected to have performance variations. It is obvious because each one is using different polyfills and different EcmaScript versions.

Simply put, the await instruction literally does what it says: it waits for the promise completion before returning its evaluated result.

It also expects that the thing being waited is likely going to be a promise-like object. That’s why has the same behavior for await (promise) or with await (non promise).

The NodeJS Event Loop already schedules the specified line to only run at the end of the current iteration. Then, and only then, it will execute the rest of it and wait for inner promises and tasks.

This is a simple way to prove the above claim:

1async function withAwait() {
2 console.log(1);
3
4 // This will make nodejs wait for the
5 // end of the current loop. Because it
6 // "expects" that a promise was given
7 // in place of 0
8 await 0;
9
10 console.log(2);
11}
12
13async function withoutAwait() {
14 console.log(3);
15}
16
17withAwait();
18withoutAwait();
1$ node example.js
2#> 1
3#> 3
4#> 2

Going back to the benchmark, it is the 1% that was being talked about earlier. And that 1% can be 100 of 10.000 ops/s, or even a 10.000 of a 1.000.000 ops/s server.

With that proved, you can see that by only using an await keyword, your function iteration flow will be interrupted multiple times. Adding every milliseconds saved when a return statement is used correctly, this can be hundreds of more operations per second.

In this way, avoiding return await can be considered a good practice that really improves performance.

Catching exceptions

As said before, if you don’t wait the promise before returning it, that promise will be returned in the same instant. Similar to a Chain of Responsibility.

This means that, instead of handling what it’ll return, it is going to pass that responsibility to the caller of the function.

And then comes the one of some correct usages of return await: Try-Catch blocks.

1// Correct usage of `return await`
2async function fn() {
3 try {
4 return await work();
5 } catch (err) {
6 return handleWorkError(error);
7 }
8}

If we change the above example and removes the await keyword:

1// return await work();
2return work();

The catch block will never be called, even if you directly throw any exceptions inside the function. That’s because the promise returned by work() function is going to directly pass its responsibility to the fn() caller.

If you had put a try-catch block outside the calling fn() function, the inner fn() catch block still wouldn’t have been executed, but the outer one would.

The simplest fix - ever.

Discounting the above exception (literally 😂), you can simply remove the await and live your life normally.

If you are using ESLint (And if don’t, you should), you can enable the no-return-await rule.

Zero cost async stack traces

If you’re a good reader, and you read the no-return-await rule, you’ve seen that it talks about preserving stack traces. But if not, no problem, I’ll explain.

Summing it up a bit, it says that just by returning the promise and letting the caller handle any possible exception, you’ll encounter a stack trace loss.

A simple example of it:

1async function foo() {
2 await bar();
3 return 42;
4}
5
6async function bar() {
7 await Promise.resolve();
8 throw new Error('BEEP BEEP');
9}
10
11foo().catch((error) => console.log(error.stack));
1$ node index.js
2Error: BEEP BEEP
3 at bar (index.js:8:9) --> (ONLY SHOWS BAR) <--
4 at process._tickCallback (internal/process/next_tick.js:68:7)
5 at Function.Module.runMain (internal/modules/cjs/loader.js:745:11)
6 at startup (internal/bootstrap/node.js:266:19)
7 at bootstrapNodeJSCore (internal/bootstrap/node.js:595:3)

But as written on the official v8.dev blog, they solved this problem with something called zero-cost async stack traces and now you can see the exact stack trace in the console:

1Error: BEEP BEEP
2 at bar (index.js:8:9)
3 at process._tickCallback (internal/process/next_tick.js:68:7)
4 at Function.Module.runMain (internal/modules/cjs/loader.js:745:11)
5 at startup (internal/bootstrap/node.js:266:19)
6 at bootstrapNodeJSCore (internal/bootstrap/node.js:595:3)
7 at async foo (index.js:2:3)

Im not gonna cover everything said there, but you can read more about it in their official blog post.

That’s It!

I hope you learned something new and can use it in your future to improve your code. See you next time!