Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Docs: no-return-await deprecation notice leads to a wrong conclusion #18166

Open
1 task
3axap4eHko opened this issue Mar 4, 2024 · 27 comments
Open
1 task
Labels
accepted There is consensus among the team that this change meets the criteria for inclusion documentation Relates to ESLint's documentation rule Relates to ESLint's core rules

Comments

@3axap4eHko
Copy link

Docs page(s)

https://eslint.org/docs/latest/rules/no-return-await

What documentation issue do you want to solve?

The documentation implies that removing await can lower performance and refers to an article that says for every await is created a microtask. According to the specification:

  1. In v8 every await leads to creation of microtask, because the engine wraps awaiting value into a IMPLICIT promise
  2. In v8 every async function leads to creation of microtask, because the engine wraps the async function into a IMPLICIT promise

Let's take a look at examples

async function a() {
	return fetchSomething();
}
async function b() {
	return a();
}
async function c() {
	return b();
}

In this example, each function (a, b, c) is marked as async, which means their return values are automatically wrapped in a Promise if not already a promise. Since fetchSomething() presumably returns a promise, a, b, and c essentially pass this promise up the chain. The JavaScript engine schedules promise resolutions as microtasks, but since there's a direct pass-through of the promise without additional await operations, the overhead is minimized to the handling of the original promise from fetchSomething(). The key point here is that the use of async alone does not introduce unnecessary microtasks; it's the handling of promises within these functions that matters.

async function a() {
	return await fetchSomething();
}
async function b() {
	return await a();
}
async function c() {
	return await b();
}

Here, by introducing await within each function, the JavaScript engine is forced to wait for the promise returned by fetchSomething() to resolve before proceeding to the next operation, even though directly returning the promise would achieve the same effect. Each await introduces a pause, requiring the engine to handle each resolution as a separate microtask. This means that for each await, there's an associated microtask for the promise resolution. The introduction of explicit await statements in this chain creates additional points at which the engine must manage promise resolutions, potentially increasing the number of microtasks and, as a result, adding overhead. This is because each await unnecessarily adds a layer of promise resolution that must be managed, even though the direct chaining of promises (as in the first example) would suffice.

Each async function declaration implies that the function's return value will be wrapped in a promise, introducing microtasks for their resolutions. However, the second example with explicit await use in each function introduces additional microtasks due to the explicit pause and wait for each promise to resolve before proceeding.

What do you think is the correct solution?

That said, using return await inside a try/catch block is a deliberate decision that prioritizes correct error handling over potential performance concerns. In this context, the performance impact is often considered negligible compared to the benefit of ensuring errors are caught and handled properly. The choice to use return await in such scenarios should indeed be meaningful and based on the specific requirements of the code rather than following a rule blindly.

Participation

  • I am willing to submit a pull request for this change.

Additional comments

No response

@3axap4eHko 3axap4eHko added the documentation Relates to ESLint's documentation label Mar 4, 2024
@fasttime
Copy link
Member

fasttime commented Mar 4, 2024

Hi @3axap4eHko, thanks for the issue. Could you point out exactly what you consider wrong in the deprecation notice of the no-return-await rule, and explain how you think it should be changed?

For context, the deprecation paragraph was added following the discussion in #17345, the rest of the documentation about that rule has not changed.

@3axap4eHko
Copy link
Author

@fasttime I would suggest to change the deprecation notice from:

This rule was deprecated in ESLint v8.46.0 with no replacement. The original intent of this rule no longer applies due to the fact JavaScript now handles native Promises differently. It can now be slower to remove await rather than keeping it. More technical information can be found in this V8 blog entry.

to
This rule was deprecated in ESLint v8.46.0 without a direct replacement. The rationale behind the rule has evolved as understanding of JavaScript's native Promise handling has deepened. Specifically, concerns about the performance impact of await in try/catch blocks are often outweighed by the benefits of clearer error handling and code readability. While await may introduce a minor overhead, this is generally considered negligible compared to the assurance of properly caught and managed errors. For more in-depth analysis, refer to this this V8 blog entry.

As you can see it clarifies the importance of await in try/catch and at the same time warns about the little performance overhead it introduces.

@fasttime
Copy link
Member

fasttime commented Mar 5, 2024

Thanks for the clarification. I'm not sure if this sentence hits the nail in the head:

The rationale behind the rule has evolved as understanding of JavaScript's native Promise handling has deepened.

The main reason for the deprecation of that rule was a change in the spec, and the consequent optimizations in V8, both in performance and in the handling of async stack traces, as mentioned in the blog post. Those changes were implemented while the rule already existed. So there were actually technical motivations, not just a shift in the understanding of how things work, that made us reconsider that rule.

The rest looks good to me. Would you like to submit a pull request to update the documentation of no-return-await, so we can discuss any tweaks right there?

@mdjermanovic
Copy link
Member

Note that this rule does not report return await in try blocks, as explained in the examples of correct code:

// In this example the `await` is necessary to be able to catch errors thrown from `bar()`
async function foo4() {
    try {
        return await bar();
    } catch (error) {}
}

I also think the current notice is correct, since there was no change in understanding but in the language specification.

@mdjermanovic
Copy link
Member

Since fetchSomething() presumably returns a promise, a, b, and c essentially pass this promise up the chain. The JavaScript engine schedules promise resolutions as microtasks, but since there's a direct pass-through of the promise without additional await operations, the overhead is minimized to the handling of the original promise from fetchSomething().

return <Promise> does not just pass through the Promise:

const promise = Promise.resolve(42);

async function foo() {
    return promise;
}

console.log(foo() === promise); // false

@fasttime
Copy link
Member

fasttime commented Mar 5, 2024

Note that this rule does not report return await in try blocks, as explained in the examples of correct code:

// In this example the `await` is necessary to be able to catch errors thrown from `bar()`
async function foo4() {
    try {
        return await bar();
    } catch (error) {}
}

I also think the current notice is correct, since there was no change in understanding but in the language specification.

This is correct. But then it seems that the only missing bit of information in the current text is the part about "the assurance of properly caught and managed errors". I understand this as an effect of the propagation of the stack trace and the asynchronous context when using await.

async function foo() {
    await null;
    await undef;
}

async function bar(useAwait) {
    return useAwait ? await foo() : foo();
}

bar(false).catch(e => console.log(e.stack));
// Will print:
//
// ReferenceError: undef is not defined
//     at foo (file:///.../index.js:3:5)

bar(true).catch(e => console.log(e.stack));
// Will print:
//
// ReferenceError: undef is not defined
//     at foo (file:///.../index.js:3:5)
//     at async bar (file:///.../index.js:7:23)

This is also explained in the V8 blog post, although not very prominently, as they were focusing mainly on performance.

@mdjermanovic
Copy link
Member

My understanding of the new version of the deprecation notice proposed in #18166 (comment) is that the rule is deprecated because return await in try {} is useful. But this rule doesn't actually report return await in try {}, so that's not the reason why the rule is deprecated.

As for the stack traces, the trade-off was already documented and isn't the reason why the rule is deprecated either.

The reason this rule is deprecated is that, due to the changes in the ECMAScript specification, return await no longer has a negative impact on performance.

@fasttime
Copy link
Member

fasttime commented Mar 5, 2024

@3axap4eHko what do you think? Does @mdjermanovic's analysis make sense to you or are we missing something?

@3axap4eHko
Copy link
Author

3axap4eHko commented Mar 5, 2024

If await has no more negative impact on performance, then maybe I misread something, but the blog post entry https://v8.dev/blog/fast-async, makes opposite feelings. And definitely I can't tell after the reading that It can now be slower to remove await rather than keeping it. If it is true, so maybe it does make sense to add some more sources to prove that and leave message as is.

@fasttime
Copy link
Member

fasttime commented Mar 6, 2024

And definitely I can't tell after the reading that It can now be slower to remove await rather than keeping it. If it is true, so maybe it does make sense to add some more sources to prove that and leave message as is.

The argument about the better performance came in with this discussion. I believe the sentence you are pointing out was introduced to refute the assumption in the following paragraph that using await would be slower ("at the cost of an extra microtask").

@3axap4eHko
Copy link
Author

And definitely I can't tell after the reading that It can now be slower to remove await rather than keeping it. If it is true, so maybe it does make sense to add some more sources to prove that and leave message as is.

The argument about the better performance came in with this discussion. I believe the sentence you are pointing out was introduced to refute the assumption in the following paragraph that using await would be slower ("at the cost of an extra microtask").

Exactly! And reading the article from the documentation does not make it clear why.

@3axap4eHko
Copy link
Author

3axap4eHko commented Mar 6, 2024

And definitely I can't tell after the reading that It can now be slower to remove await rather than keeping it. If it is true, so maybe it does make sense to add some more sources to prove that and leave message as is.

The argument about the better performance came in with this discussion. I believe the sentence you are pointing out was introduced to refute the assumption in the following paragraph that using await would be slower ("at the cost of an extra microtask").

I've looked at the example of code provided in the discussion, and found it a little bit inaccurate. The problem of that example that it states on execution performance by counting ticks, but ticks are not fixed to something, it not time or processor operations. The example bellow shows that async () => Promise.resolve() and async () => await Promise.resolve() creates 2 more promises than () => Promise.resolve()

import { createHook } from "node:async_hooks";
 
const logs = [];
let logEnabled = false;
const log = (...data) => {
  if (logEnabled) {
    logs.push(data);
  }
};
 
const asyncHook = createHook({
  init() {
    log("init");
  },
});
 
async function test(name, fn) {
  let tick = 0;
  const tock = () => {
    tick++;
    log("tick", name, tick);
  };
  const rest = Promise.resolve().then(tock).then(tock).then(tock).then(tock).then(tock);
  logEnabled = true;
  log("start", name);
  await fn();
  logEnabled = false;
  await rest;
}
 
setTimeout(() => {}, 100);
 
asyncHook.enable();
await test("promise-sync", () => Promise.resolve());
await test("promise-async", async () => Promise.resolve());
await test("promise-async-await", async () => await Promise.resolve());
asyncHook.disable();
console.log(logs);

The output shows the same amount of created promises for both async functions.

[
  [ 'start', 'promise-sync' ],
  [ 'init' ],
  [ 'init' ],
  [ 'init' ],
  [ 'init' ],
  [ 'tick', 'promise-sync', 1 ],
  [ 'start', 'promise-async' ],
  [ 'init' ],
  [ 'init' ],
  [ 'init' ],
  [ 'init' ],
  [ 'tick', 'promise-async', 1 ],
  [ 'init' ],
  [ 'tick', 'promise-async', 2 ],
  [ 'tick', 'promise-async', 3 ],
  [ 'start', 'promise-async-await' ],
  [ 'init' ],
  [ 'init' ],
  [ 'init' ],
  [ 'init' ],
  [ 'init' ],
  [ 'tick', 'promise-async-await', 1 ],
  [ 'tick', 'promise-async-await', 2 ]
]

So I've decided to measure execution time of 1_000_000 functions and results are consistently showing a faster execution of async () => Promise.resolve() over async () => await Promise.resolve()

⭐ Script  /home/zource/projects/overtake/__benchmarks__/await-vs-pass.js
  ⇶ Suite  Async await performance
    ➤ Perform  1000000 calls
      ✓ Measure 1000000 calls of function that returns Promise.resolve()
        ┌──────────┬──────────┬──────────┬──────────┬────────────┬─────────┐
        │ (index)  │ med      │ p95      │ p99      │ total      │ count   │
        ├──────────┼──────────┼──────────┼──────────┼────────────┼─────────┤
        │ 0.000084 │ 0.000094 │ 0.000158 │ 0.000285 │ 116.838682 │ 1000000 │
        └──────────┴──────────┴──────────┴──────────┴────────────┴─────────┘
      ✓ Measure 1000000 calls of async function that returns await Promise.resolve()
        ┌─────────┬──────────┬──────────┬──────────┬────────────┬─────────┐
        │ (index) │ med      │ p95      │ p99      │ total      │ count   │
        ├─────────┼──────────┼──────────┼──────────┼────────────┼─────────┤
        │ 0.00011 │ 0.000124 │ 0.000268 │ 0.000425 │ 166.154862 │ 1000000 │
        └─────────┴──────────┴──────────┴──────────┴────────────┴─────────┘
      ✓ Measure 1000000 calls of async function that returns Promise.resolve()
        ┌──────────┬─────────┬──────────┬──────────┬────────────┬─────────┐
        │ (index)  │ med     │ p95      │ p99      │ total      │ count   │
        ├──────────┼─────────┼──────────┼──────────┼────────────┼─────────┤
        │ 0.000107 │ 0.00012 │ 0.000246 │ 0.000391 │ 154.897689 │ 1000000 │
        └──────────┴─────────┴──────────┴──────────┴────────────┴─────────┘

@3axap4eHko
Copy link
Author

@fasttime @mdjermanovic please notice that even if majority of calls are almost the same with a little fluctuations, the average performance shows that there is a difference with these two approaches, and approach with await shows the worst results, I didn't have a chance to participate that discussion, but definitely the observation of created promises and performance benchmark show the opposite of the discussion result.

@fasttime
Copy link
Member

fasttime commented Mar 7, 2024

I think that the current notice makes sense in stating that it can be be slower to remove await. This is in line with the observed fluctuations, and with the fact that different measurements are producing different results.

Personally, I don't feel strongly about the current phrasing, and if there is an objective way to improve it, I'm sure the team will be open to make changes. But I don't think we should bother benchmarking the test cases of a deprecated rule. There is no longer a recommendation to use or avoid await because of performance in that rule, and performance tests are not needed at this time.

@mdjermanovic
Copy link
Member

The difference in performance between return and return await in an async function seems small and could be an implementation detail. Perhaps the results would be different in another engine or another version of v8. But the new fact that return now has more ticks than return await stands, as it's dictated by the spec? So, maybe we could update the text in the deprecation notice - instead of "slower" we could say something like that removing await can now make an extra microtask, to directly state the opposite of "at the cost of an extra microtask" from the document (which wasn't using terms "faster"/"slower" anyway)?

@3axap4eHko
Copy link
Author

@fasttime @mdjermanovic The amount of ticks does not reflect execution time or performance quality, it is just the matter on how the engine prioritizes promises resolution over promises creation.

  [ 'start', 'promise-async' ], // start
  [ 'init' ], // 1
  [ 'init' ], // 2
  [ 'init' ], // 3
  [ 'init' ], // 4
  [ 'tick', 'promise-async', 1 ], // squeezed a microtask tick
  [ 'init' ], // 5
  [ 'tick', 'promise-async', 2 ],
  [ 'tick', 'promise-async', 3 ],
  [ 'start', 'promise-async-await' ], //start
  [ 'init' ], // 1
  [ 'init' ], // 2
  [ 'init' ], // 3
  [ 'init' ], // 4
  [ 'init' ], // 5
  [ 'tick', 'promise-async-await', 1 ],
  [ 'tick', 'promise-async-await', 2 ]

As I said before both async calls have created the same amount of promises (5 promises both) internally. Also, saying at the cost is not really correct, because there is nothing to trade off. Keep in mind the fact that squeezed microtask does something meaningful in real life application, that eventually shall be done anyway, unless you just want to count these ticks. My goal by creating this issue is to make it clear for other people that there is nothing wrong by having await or avoiding await, because it does not affect performance at all and decision should be made purely on understanding what the code should accomplish. Unfortunately a lot of people seeing word slower have impression of the low performance.

@fasttime
Copy link
Member

fasttime commented Mar 8, 2024

My goal by creating this issue is to make it clear for other people that there is nothing wrong by having await or avoiding await, because it does not affect performance at all and decision should be made purely on understanding what the code should accomplish. Unfortunately a lot of people seeing word slower have impression of the low performance.

So what change do you suggest?

@3axap4eHko
Copy link
Author

@fasttime I would suggest to change the deprecation notice from:

This rule was deprecated in ESLint v8.46.0 with no replacement. The original intent of this rule no longer applies due to the fact JavaScript now handles native Promises differently. It can now be slower to remove await rather than keeping it. More technical information can be found in this V8 blog entry.

to This rule was deprecated in ESLint v8.46.0 without a direct replacement. The rationale behind the rule has evolved as understanding of JavaScript's native Promise handling has deepened. Specifically, concerns about the performance impact of await in try/catch blocks are often outweighed by the benefits of clearer error handling and code readability. While await may introduce a minor overhead, this is generally considered negligible compared to the assurance of properly caught and managed errors. For more in-depth analysis, refer to this this V8 blog entry.

As you can see it clarifies the importance of await in try/catch and at the same time warns about the little performance overhead it introduces.

Still this one

@3axap4eHko
Copy link
Author

This is the corrected original sentence according to benchmarks, would like to keep it?

This rule was deprecated in ESLint v8.46.0 with no replacement. The original intent of this rule no longer applies due to the fact JavaScript now handles native Promises differently. It still can be slower to keep await rather than removing it. More technical information can be found in this V8 blog entry.

@fasttime
Copy link
Member

fasttime commented Mar 9, 2024

This is the corrected original sentence according to benchmarks, would like to keep it?

This rule was deprecated in ESLint v8.46.0 with no replacement. The original intent of this rule no longer applies due to the fact JavaScript now handles native Promises differently. It still can be slower to keep await rather than removing it. More technical information can be found in this V8 blog entry.

Thanks for the update!

The performance difference in using await has been the topic of some controversial discussions in the past (see #17613). I'm afraid that changing the statement about the speed like you suggest would refuel those discussions without substantially adding to the correctness the notice: if it can be slower, it can be not. Note that the current wording aims to mitigate the impression that speed is a concern, not to reinforce it.

For this reason, I'm not in favor this change.

@3axap4eHko
Copy link
Author

So let's just remove the sentence about remove await is slower. It's pretty clear from what I've provided already, and from the specification this is not true. Even if all my benchmarks shows better performance without await, I'm still in favor of removing misleading statement, that is cannot be proved nor by benchmarking or by spec reading. What do you think?

@JoshuaKGoldberg
Copy link
Contributor

I think that the current notice makes sense in stating that it can be be slower to remove await. This is in line with the observed fluctuations, and with the fact that different measurements are producing different results.

I agree with this point, and think it has remained salient through the rest of the discussion. Saying it can be slower isn't the same as saying it is slower, or even is likely to be slower.

I'm afraid that changing the statement about the speed like you suggest would refuel those discussions without substantially adding to the correctness the notice

Agreed. Promises tend to bring up a lot of often-unnecessary bikeshedding. If there is a way to have the docs be slightly better phrased, I don't think that phrasing would be very much better, nor the long process to get there worth the time expenditure.

@3axap4eHko
Copy link
Author

@JoshuaKGoldberg it is saying it can be slower without actual proof.

@fasttime
Copy link
Member

I also don't like the part where it says that removing await could be slower, but if we remove that sentence without a replacement, there will still be the impression that it's more performant to avoid await, because of the next paragraph. I think we should indicate that the noted cost of the extra microtask only relates to a particular implementation (V8) at the time the rule was created.

@mdjermanovic
Copy link
Member

Agreed, we should update the deprecation text to say that the noted cost of the extra microtask no longer exists, and remove the word "slower".

the noted cost of the extra microtask only relates to a particular implementation (V8) at the time the rule was created.

My understanding is that the ECMAScript spec required the extra microtask at the time the rule was created (and that V8 didn't actually have that extra microtask, which was considered a bug but eventually became correct behavior when the spec was changed).

@mdjermanovic mdjermanovic added rule Relates to ESLint's core rules accepted There is consensus among the team that this change meets the criteria for inclusion labels Mar 22, 2024
@fasttime
Copy link
Member

@3axap4eHko This issue has been accepted. Since you are the initiator, would you be willing to create a pull request with the suggested changes?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
accepted There is consensus among the team that this change meets the criteria for inclusion documentation Relates to ESLint's documentation rule Relates to ESLint's core rules
Projects
Status: Implementing
Development

No branches or pull requests

4 participants