Async by default with 'use async';

Problem

While coding, it is frequent to forgot the await keyword before returning a Promise.

This leads to unexpected results:

  • The Promise is executed, but your code is now desynchronized
  • You test the result, expect null of false, but the Promise is evaluated to true, so your test fails

It can take ages before you actually realize what is happening.

In addition, writing async / await everywhere in your code is manual and painful.

Async should be the default mode for modern javascript.

We should be able to opt-in : async by default.

Proposal

Let the developer specify it wants its code to be async by default, by adding a 'use async'; instruction to the EcmaScript specification.

'use strict';
'use async';

When used, all functions are considered as async and are expected to return a Promise. Legacy browser functions are converted to promises if needed, but that should not be necessary since await 42 always fullfills to 42.

Example 1
const f = async () => {}
await f();

can now be written as :

'use async';
const f = () => {}
f();
Example 2
const f1 = async () => {}
const f2 = async () => {}
await Promise.all([f1, f2]);

can now be written as :

'use async';
const f1 = () => {}
const f2 = () => {}
Promise.all([f1, f2]);

The interesting part is that the 'use async'; instruction can be automatically added by bundlers, so you are guaranted your Promises will work as intented.

Main benefits:

  • Peace of mind
  • Modern Javascript by default
  • MicroTasks by default
  • WebPerf by default

I agree that it can be annoying when you gets odd errors because you forget an await.

But making all function calls do an implicit await has a lot of issues. Let me just point one out:

const userData = {}

async function getUserData() {
  await doSomethingAsync()
  return userData
}

async function setUserData() {
  await doSomethingAsync()
  userData.propCount = 2
  userData.username = getName()
  userData.birthday = getBirthday()
}

In the above example, setUserData() will update the userData object in multiple steps. It’s vital that these steps all execute before we let the event loop handles anything else, as these individual updates are supposed to be atomic. One of the great benefits of async/await is the fact that we can guarantee that nothing will interrupt us as we’re running a particular piece of sync logic (as apposed to, say, multi-threading where you have to add all sorts of extra locks in place, because the other thread is literally running at the exact same time).

If we converted this code to use async, then the synchronous calls to getName() and getBirthday() would implicitly receive an await, which means execution is allowed to pause at those points, and the event loop is allowed to execute another task in between, like, someone trying to call the getUser() function, who’s going to end up with a partially populated user object - the user object they receive might claim that there’s two properties on the object (propCount was set to 2), but we only had the chance to set one, because execution paused.

2 Likes

You do have a point.

One way to address this need would be to create a transaction

Promise.transaction([f1, f2]);

or

Promise.sync([f1, f2]);

or

transaction {
  instruction 1;
  instruction 2;
}

or

sync {
  instruction 1;
  instruction 2;
}

This way 99% of your code stays async by default, and for the very few use cases you need to be sync, use a transaction / sync blockcode.

That sounds like we’re trading one footgun for another. We go from “our program obviously doesn’t work, as it crashes every time this chunk executes (we forgot to await a promise)” to "every once in a while, we end up with invalid data, and we have no idea why (we forgot to put code into a transaction). The latter is much, much harder to debug.

But, that’s just one issue. Another one is performance. await isn’t free - each time you use an await, Javascript will let the entire call stack empty itself before it unpauses your function and executes whatever comes next. This is a lot of extra overhead for each function call - normally when you return from a function, the engine simply has to pop the top stack frame from the call stack, but if everything gets awaited, it needs to save everything off, and let the call stack empty itself, before it hands control over to the event loop to execute whatever comes next.

Let me suggest an alternative idea that has the same spirit as your idea, but without some of these major issues. When you’re in an async function, and call a function that returns a promise, that promise is automatically awaited. There’s still some performance overhead for this, and it certainly still has issues, but I think this gets rid of the biggest problems.

1 Like

Because it is optional, having a developer switch on async by default would suppose pro and cons have been evaluated. The WebPerf benefits lie in MicroTasking and the very fact all code you write can be interrupted at any moment, so the main thread is not janky.

But I agree it is an optimistic / all-in vision, excepted for parts explicitely marked as synchronous .

So you propose to get rid off await, but still keep async in functions declarations ?

Like this :

'use await';
const f = async () => {}
const main = async () => {
  f();
}
main();

Yep. I don’t really like my idea either, but I do think it’s an improvement.

In general, I like having explicit await markers that let me know when a function pauses, as this lets me easily see when the event-loop will have a chance of executing, and I can know that anything I want to execute atomically needs to be between awaits. I feel like I remember seeing elsewhere that the await keyword is intentionally required for this exact purpose, so it’ll probably be difficult to convince the committee to provide an option to turn them off, if they originally wanted them explicit - but I could be misremembering.

I personally feel like the original issue of “forgetting to use await” is mostly a type-safety issue. You called a function and tried to use its returned value (a promise) as something it wasn’t. Something like typescript solves this class of problems in a clean way.

2 Likes

Yes, it is.

Agree.

For efficiency reasons, you often don’t want to await a Promise and changing default behavior might break lots of web code.

Maybe you could do this using a different function modifier keyword. Eg; sequential instead of async, which will be like async, but it will automatically await all promises in the function call. Semantically, it means something like “Run async promise calls sequentially”.

sequential async function getUserData() {
  doSomethingAsync() // automatically awaited
  return userData
}

sequential async function setUserData() {
  doSomethingAsync() // automatically awaited
  userData.propCount = 2
  userData.username = getName()
  userData.birthday = getBirthday()
}

That way, if a developer wants, they can use “sequentially” everywhere by default to “auto-await”, but it’s not an automatic thing.

You are trading one problem that can be very easily discovered by automated tools (linters, unit tests, TypeScript, you name it) for a set of hidden problems that are very hard to track down.

These hidden problems arise for the tricky semantics of this proposal. Everything is async, and everything is awaited, but when is it awaited? That’s extremely important for performance.

Here is one example. When you see in the code:

await foo();
await bar();

That’s immediately a red flag. Two asynchronous operations that seemingly don’t depend on each other are running sequentially, being much slower than they should. You immediately think of using Promise.all().

But with this new scope, since everything becomes async, but you can’t tell which things are really async and which ones are not, either you run Promise.all() all the time just in case, making the code unreadable, or you will have performance issues because you accidentally execute sequential asynchronous operations that should be parallel.

Another example of these hidden problems is fire-and-forget behavior. For example, telemetry or exception logging. Most likely you don’t want to pause your program until the server commits such logs. But with this scope, there is no way to fire-and-forget an asynchronous fetch() anymore.

And in fact, it’s pretty much impossible to use Promise.all anymore. In your Example 2 you are passing functions to Promise.all, but that method consumes promises, not functions. As soon as you call the functions, this new scope will await them, which means they will run sequentially, and Promise.all will only trick the reader into thinking the code is concurrent when it’s not.

One possible solution to the Promise.all issue is to have this new automatic await be lazy, meaning that things are awaited at the very last moment, when you try to dereference the value, and as long as functions consume promises, they are not awaited. That way, Promise.all would still work, because the input promises would remain promises. However, now you can accidentally dereference a promise too early and still force sequential execution by mistake. Also, it might not be trivial for the JS engines to figure out when some code is trying to dereference a promise and when not. console.log(promise) would be ambiguous. Are you trying to debug the promise or the value?

The status quo might be annoying when you are developing and forget some async or some await, but tooling will help you catch those problems before they reach production. The problems introduced by this proposal are much harder to detect, because they often don’t cause crashes or incorrect calculations, but will affect the end user by introducing many sources of bad latency that are really hard to pinpoint.

2 Likes

Makes sense. If a keyword exists to make everything auto-awaited, that does cause severe issues with Promises you don’t want to await. Probably better to drop the sequential thing. Might cause more problems that it fixes.