Skip to main content

Command Palette

Search for a command to run...

Async by default: JavaScript++

Editor’s Note: I’m not a language designer, nor a compiler writer. I’d love to get feedback from others on why this idea would/wouldn’t work.

Updated
4 min read
Async by default: JavaScript++
S
With 20+ years of dev experience, I'm an online educator, mentor, and consultant--teaching practical, advanced topics to developers like you on how to write: ✅ new AI/LLM features ✅ maintainable JS code ✅ scalable web apps ...yup that's about it! 🎤

Originally appeared on my Medium blog.

Promises are pure genius, and were a significant addition to the JavaScript language in ES2015, built on the shoulders of giants. Now I’m convinced they’re all that developers need to handle any asynchronous situation in their code. (Move along, RxJS.)

But it’s also obvious to me that it was added on later, with backwards compatibility in mind. Which is great, but it also makes me wonder… what if JavaScript was async by default instead? I call this idea JavaScript++.

Let me back up with a basic Promise/await concept that may be new to you — what I call “auto-boxing”:

let a;
a = 5; // 5
a = Promise.resolve(5); // a is a Promise(5)
a = await Promise.resolve(5); // 5
a = await 5; // auto-boxed as a Promise(5), then unwrapped by await

All of these statements are functionally equivalent, even the last one; it’s true! Let’s expand this concept a little bit more by looking at functions:

let syncFunc = () => 5; // returns 5
let asyncFunc = async () => 5; // returns a Promise(5)
let a;
a = syncFunc(); // 5
a = asyncFunc(); // a is a Promise(5)
a = await asyncFunc(); // 5
a = await syncFunc(); // auto-boxed as a Promise(5), then unwrapped

Isn’t this cool? What is means is that “async” is backwards compatible — you don’t have to use it on a Promise! Which begs the question… what if the language could auto-box/unbox everywhere by default? Let’s change our syntax slightly into psuedo-code where “::” signifies a Promise auto-box preference:

let syncFunc = () => 5;
let asyncFunc = () ::=> 5; // returns a Promise(5)
let a;
a = 5;
a = Promise.resolve(5); // a is a Promise(5)
a =:: Promise.resolve(5);
a =:: 5;
a = syncFunc();
a = asyncFunc(); // a is a Promise(5)
a =:: asyncFunc();
a =:: syncFunc(); // auto-boxed as a Promise(5), then unwrapped

The reason we must distinguish between “=” and “=::” is because sometimes we want to allow async work (such as an API call) to run “in the background” while other work is done immediately; we don’t want to block/await immediately. (This is similar to multi-threading concepts where a background thread isn’t “joined”/awaited until absolutely necessary.)

But, do we really need to distinguish between a “() =>” and a “() ::=>” function at all? Why not always return a Promise, and let the caller can decide whether to await “::” or not?

let func = () => 5; // auto-boxed as a Promise
let func = () ::=> 5; // which syntax do you prefer?
let a;
a =:: func();
a = func(); // a is a Promise(5)

Another benefit of ALL functions returning a Promise is that you never have to decide whether a function is “async” or not. This is often a cause for refactoring, when suddenly a function depends on another async function; it must either become async itself, or awkwardly leverage the Promise’s “.then(…)” API. This problem goes away if EVERY function is async!

Finally, to truely make async/Promises default (because we assume maybe that the majority of Promises are immediately awaited ?), we simply swap the “::” syntax to mean “do NOT await”:

let func = () => 5; // auto-boxed as a Promise(5)
let a;
a = func(); // Promise(5) is unwrapped as a 5
a =:: func(); // a is a Promise(5)
// ... do more work ...
a = a // Promise(5) is unwrapped as a 5
a = a // 5 is auto-boxed into a Promise(5), then unwrapped as a 5

Of course, consideration for other operators would also be needed, for example:

let func = () => 5; // auto-boxed as a Promise (5)
let a;
a = func() + func() // plus unwraps all promises
a = func() + 5 // OK
a = func() +:: func() // invalid - cannot add a Promise
a = 5 +:: func() // invalid - cannot add a Promise
a = func() +:: 5 // invalid - cannot add a Promise

I’ll leave it here as an excercise for the reader. 🤔

Admittedly, this JavaScript++ would definitely break backwards compatibilty, and is therefore probably more of a mental excercise than anything else. But… maybe it’s inspired someone smarter than me to figure out the next great leap in JavaScript async handling. Who knows?

“Async by default” — Would it work? Let me know what you think in the comments below.

M
Manik3y ago

I agree with Paul-Sebastian Manole here. Also let's not forget how the JavaScript work behind the scenes. Promises are sent to the event loop to be processed once the stack is clear. There is a reason async functions are different from synchronous code that we write.

3
S

Hi Manik, thanks for the comment. You're right, JS is single threaded, but Promises technically use a separate "micro-queue" which recieved precendence over the event loop.

In either case, I mentioned "similar to multi-threading concepts" because of that fact of queues vs. threads. So you're correct, they are not the same!

But, if for a moment we set aside starvation (which is a real concern), and also web workers/etc -- then we really can conceptualize Promises as threads! And it's a very powerful way to think about them.

I've written up an Advanced JavaScript Promise slide deck; if you're interested I'll post the link.

M
Manik3y ago

Steven Olsen sure would love to read. Drop me a link 👍

S

Manik Here you go, enjoy!

https://slides.com/solsen-tl/javascript-promises-beginner-to-advanced

D

I think you are missing the point of async. Async is used to encapsulate the promise of a long operation eventually returning something, not of just returning 5. Promise chains are like state machines. You don't want to create a state machine to "auto-promise" everything! Plus, there is no joining of threads when awaiting in JavaScript. JavaScript is single threaded and only IO operations run in parallel (network, disk, other Web APIs). JavaScript CPU bound promises (like returning 5 or the result of a calculation with local data) still run in the single/main program thread. And a promise still runs even if you don't await it, so await only blocks the thread in the sense that you schedule the promise to run now and take its value, instead of letting the state machine schedule it.

1
S

Hi Paul-Sebastian Manole, thanks for your comment!

I've answered your point about single-threaded, which you havea good understanding of, here: https://srjsdev.hashnode.dev/async-by-default-javascript#clabbbxru00fuaenv4a61e7ei

If we set aside side-effects, such as timers and network delays, then Promises are totally predictable in terms of the order they execute, thanks to their micro-queue!

Promise chains are like state machines. You don't want to create a state machine to "auto-promise" everything!

Can you elaborate? Why not? If raw efficiency is your concern, then I'd agree (currently......).

But in fact a ".then()" Promise chain does exactly this "auto-promise", since ".then()" always returns a Promise. Consider this:

Promise
  .resolve(5)
  .then(num => num + 1)
  .then(six => six * 2)
  .then(twelve => console.log(twelve))

The callbacks themselves are largely irrelevant. However this is a simple chain of repetitiously auto-boxing a value into a Promise, and then unwrapping/awaiting the Promise into its value. Isn't it?

S

Very good article for all of us.

1
E

I think it could create extra confusion.

Also, the autoboxing thing you describe doesnt happen. If the thing isn't a promise, await doesn't make it a promise. It just does nothing.

1
S

That's why it's called JavaScript++, it's not backwards compatible and requires a new mental model 😉.

You could be right Eric Kwoka, I'm not familiar with the implementation details. That sounds like an efficiency optimization, though. But conceptually, to cover all the cases in a uniform way, the auto-boxing strategy could work.

(Promises are so cool. Just try Promise.resolve(Promise.resolve(5)) if you don't believe me, lol.)

But actually, I introduced it more as a (valid) concept to bridge us over to the async-by-default model.