The problem I encounter with async/await (in JS) is that while an async method can call a non-async-function and do somewthing with the result of that, the reverse is not true, a sync function can call async-function but can not us the result of that in any way, except pass it on or upwards.
What makes it worse is that you can not simply modify a sync-function to become an async-function, if it has existing callers because those would likely break them.
This affects the whole tree of possible function calls in my program. If at some level I have a sync function but I see it needs to get to some data that only async fuction can provide, I may need to change a whole call-chain of my call-tree, not just modify a single function in that tree.
Then as I develop my program I need to make the decision for every function; should it be sync or async? In many cases it could be either one so which should I choose? Making it async would seem to make it easier to evolve the program later. But then would it make sense to make every function async?
This is often brought up as if it were a problem, but I see async functions as something similar to IO in Haskell. Almost all asynchronous functions I write are asynchronous because they will do IO of some sort. Async functions end up being markers of where IO may occur, which is very useful. It is very rare that I need to change a function from being sync to async (and the inverse pretty much never happens), and when that happens it's usually not a big deal (the caller is highly likely to be an async function within a short stack distance, so only one or two functions in the middle normally need to change).
In summary, async is something that looks problematic in theory, but in practice it just works really well!
If you want to have annotations on IO code you could get it using attributes - and you could even go further by using more precise annotations like `network`, `disk`, etc. The problem with using the type system is that now you need to account for the distinction everywhere (ex. interfaces/traits must support IO-based implementations) only to carry this metadata which should not affect the behavior. In Haskell, IO exists not to track a behavior but to enforce it in a lazy language. In Rust is done due to the lack of a runtime.
This is only true in Javascript though - even though you have the same function coloring aspect in most other languages with async/await, the other ones do not come with this benefit since synchronous I/O is not only possible but the classical default.
Javascript's async-everything is really unique in a domain where async is almost always bolted on to synchronous-everything in some sort of incompatible subecosystem.
I mostly do this in Dart (though even Dart also has sync IO, it’s just not supposed to be used often), but yeah other languages may not have this benefit.
Async/await syntax doesn't guarantee what kind mechanism is servicing the async work. Consider a situation where you have limited threads (perhaps even only one servicing both sync and async calls).
Blocking that thread to wait for an async call would prevent the async call from completing and would be a deadlock.
As noted, you can run sync code from an async context. Even if you have a thread pool, blocking in sync has a chance to block and consume an async worker thread. That can also cause deadlocks.
They don't make it easy to wait synchronously because it's a bad idea. Making the syntax more amicable to blocking is a worse idea.
Tactically, this problem is commonly known as “colored functions”[1], and the only option in JS is to have some other runtime coordinate your function execution; in JS, that solution is Effect[2]
There are a lot of libraries which can help you deal with this, but ultimately the parent is right.
I usually arrange my programs to have a call tree of all my async code, and separate call trees of sync processing work. You want to know ahead of time which is which. If you have a sync function which needs data that’s only available via a network request, take that data in as a function parameter or something. And make the caller responsible for making that data available before the function is called.
It’s a simple model. It’s fast and quite easy to understand once you’re used to it. But you do need to plan ahead, and structure your programs with a plan.
A hallmark of good architecture is adaptability to unexpected changes in requirements. Planning ahead helps with 'known unknowns', but it's impractical when building across N years in a dynamic environment - "knowing ahead of time" is just not possible for anything non-trivial. You need strong architectural primitives that don't scale based on developers' omniscience.
For example, say you have a workflow where, when someone signs up, you generate a user label, e.g., `$firstName $lastName`. You decide to move that to a function that might consider their personal title, preferred name, etc. Currently, it's a pure sync operation. Then, you discover people don't fill out that form at all, but some log in with Google, and you can use the name there as a fallback. Under flexible, this change is local: you can put that into your `createUserLabel(userInfo)`, and it can decide to fire off an API call to fill in any missing data, etc. In Promises land, this would taint the entire tree of everything everywhere that called that method, and all of those things must evolve or be refactored. In an Effectful system, this (previously unplanned) change remains isolated to that one function. Multiply that by every decision over multiple years for software looking for PMF, and requiring developers to "know ahead" severely slows down your ability to evolve.
Languages with effect systems typically let callers inherit the effects of their callees (e.g., calling an async function means the caller also is async), or force them to handle the effect (e.g., spawn the async call as a task and waiting synchronously for it to finish).
Effects are just a generalization, where async/await is one particular effect.
But: The fact that an operation now does some kind of I/O, or waits for user input, or whatever else you might express using async, has an _enormous_ impact on the architecture of your program. The “virality” of async is completely a feature, because it forces you to actually deal with that change, resulting in much more robust software.
It’s “inconvenient” because the architecture of your program changed. That’s what the job is, though. Languages that don’t help you here (by hiding that you made a change with huge ramifications) make it actively harder to deliver working software, in my opinion. You get there faster, but it won’t keep working.
The problem with effects systems is that effects aren't generalisable. Every effect is unique. Sometimes they can be applied automatically and sometimes not.
You can have the compiler automatically recompile map with async to make map<async>, likewise map<pure> and map<nofail> and map<noblock> but they will not be optimal; map<async> could be parallel but isn't. And you probably want to control the amount of parallelism at each call site, which just makes it a completely different function. It's likely that you wrote map in a way that uses a loop counter and it's possible the compiler can't prove it's pure. map<abortable> is likely correct, but the compiler has absolutely no way to prove that, and other functions won't be correct if you naively make them possible to abort from outside.
It's a fundamental architecture change only if we assume async == slow (or potentially slow) which isn't always true. Otherwise you might want to run something inline that the language designer made async - such as writing a file in /tmp.
That’s a good example. I would refactor that code to have a different signature and change all callers to fetch the relevant data.
I’d probably insist on doing that even in a blocking language where it’s not necessary. Interspersing database or network requests all through a codebase is horrible. Before you know it, someone is calling that function in a loop and you’re doing N serialised database queries. And you can’t even tell that that’s happening from the function signature. Your program just gets slow as your database grows. To say nothing of the correctness problems from issuing these queries outside of a transaction.
I worked on a project that was written like this in Python. The code was packed full of “convenient” sql queries. Some http requests took seconds to render. Turns out those request handlers were issuing thousands of individual sql queries, loading hundreds of megabytes from our database. A lot of the queries were redundant. The backend was just overfetching the same data over and over in tiny helper functions. Because of how the code was written, fixing performance required huge refactors all over the codebase.
File, network and database queries should not be spread all over “for convenience”. Fetching user data and processing it are different tasks. They generally shouldn’t be combined into a single function.
The solution to this is assume an async spine to your program, and branch off to as much sync code as possible. It's the same lesson you learn wrangling the IO monad in Haskell, or dependencies (like databases) in OO-land.
To go with this, don't be afraid of changing your code. If you get an unexpected change that means a whole hierarchy has to become async, bite the bullet and change the hierarchy. Such things happen.
This works for all applications, but not libraries where you don't control your callers. In that case it may make sense to make something async pre-emptively if you think requirements might change in a way that requires it but you can never predict every change successfully and you might need to make a V2 library.
I'm curious about your mental model. Would it be accurate to say that the async tree is the "IO program" and the sync functions operate on pure data, or is it more complicated than that?
But then would it make sense to make every function async?
No, that doesn't make sense at all! You're being too reductionist...
I/O has its place in every real world program, and the true limitation (or what you call a "problem") are the single-threaded runtimes of JavaScript. It's not a question of whether you should mark your functions async or not, as if it's an issue of consistency in the call-tree of your program. The true question should rather be whether your functions are I/O-bound (and would actually block the single-threaded event loop) or are solely compute-bound.
You're forgetting the fact that async/await in JavaScript was a historical design choice to prevent the callback-hell that came with single-threaded concurrency. So if you'd want to get back to that callback-hell (and convert async functions back to "some-form-of" sync), you still can[0]:
// some dummy async function that doesn't really do any I/O
async function add(
a: number,
b: number,
): Promise<number> {
return a + b;
}
// convert async function back to sync to enjoy callback-hell again
function addUnpromisified(
a: number,
b: number,
cb: ((result: number | null, reason: any) => any),
): void {
add(a, b)
.then((result) => { cb(result, null); })
.catch((reason) => { cb(null, reason); });
}
You can think of async/await as an evolution of generators[1], as every await yields control back to the event loop. I'd actually encourage you to write some of your programs' behavior with generators if you've never done that before. Generator functions will yield control back to the caller, which is an interesting way to design programs when the caller is you instead of the event loop.
It's in Python, but David Beazley still has one of the best explanations on that topic, and shows the how and why you'd want to design an event loop live on stage[2].
Comments
The problem I encounter with async/await (in JS) is that while an async method can call a non-async-function and do somewthing with the result of that, the reverse is not true, a sync function can call async-function but can not us the result of that in any way, except pass it on or upwards.
What makes it worse is that you can not simply modify a sync-function to become an async-function, if it has existing callers because those would likely break them.
This affects the whole tree of possible function calls in my program. If at some level I have a sync function but I see it needs to get to some data that only async fuction can provide, I may need to change a whole call-chain of my call-tree, not just modify a single function in that tree.
Then as I develop my program I need to make the decision for every function; should it be sync or async? In many cases it could be either one so which should I choose? Making it async would seem to make it easier to evolve the program later. But then would it make sense to make every function async?
This is often brought up as if it were a problem, but I see async functions as something similar to IO in Haskell. Almost all asynchronous functions I write are asynchronous because they will do IO of some sort. Async functions end up being markers of where IO may occur, which is very useful. It is very rare that I need to change a function from being sync to async (and the inverse pretty much never happens), and when that happens it's usually not a big deal (the caller is highly likely to be an async function within a short stack distance, so only one or two functions in the middle normally need to change).
In summary, async is something that looks problematic in theory, but in practice it just works really well!
If you want to have annotations on IO code you could get it using attributes - and you could even go further by using more precise annotations like `network`, `disk`, etc. The problem with using the type system is that now you need to account for the distinction everywhere (ex. interfaces/traits must support IO-based implementations) only to carry this metadata which should not affect the behavior. In Haskell, IO exists not to track a behavior but to enforce it in a lazy language. In Rust is done due to the lack of a runtime.
I recommend reading https://degoes.net/articles/no-effect-tracking . In summary, most languages could do with the Go/Java virtual thread async model dropping async/await entirely.
This is only true in Javascript though - even though you have the same function coloring aspect in most other languages with async/await, the other ones do not come with this benefit since synchronous I/O is not only possible but the classical default.
Javascript's async-everything is really unique in a domain where async is almost always bolted on to synchronous-everything in some sort of incompatible subecosystem.
I mostly do this in Dart (though even Dart also has sync IO, it’s just not supposed to be used often), but yeah other languages may not have this benefit.
Agreed on async. You better know if a function does IO, hiding that can lead to nasty surprises.
Async/await syntax doesn't guarantee what kind mechanism is servicing the async work. Consider a situation where you have limited threads (perhaps even only one servicing both sync and async calls).
Blocking that thread to wait for an async call would prevent the async call from completing and would be a deadlock.
As noted, you can run sync code from an async context. Even if you have a thread pool, blocking in sync has a chance to block and consume an async worker thread. That can also cause deadlocks.
They don't make it easy to wait synchronously because it's a bad idea. Making the syntax more amicable to blocking is a worse idea.
Tactically, this problem is commonly known as “colored functions”[1], and the only option in JS is to have some other runtime coordinate your function execution; in JS, that solution is Effect[2]
[1] https://journal.stuffwithstuff.com/2015/02/01/what-color-is-...
[2] https://effect.website/
There are a lot of libraries which can help you deal with this, but ultimately the parent is right.
I usually arrange my programs to have a call tree of all my async code, and separate call trees of sync processing work. You want to know ahead of time which is which. If you have a sync function which needs data that’s only available via a network request, take that data in as a function parameter or something. And make the caller responsible for making that data available before the function is called.
It’s a simple model. It’s fast and quite easy to understand once you’re used to it. But you do need to plan ahead, and structure your programs with a plan.
A hallmark of good architecture is adaptability to unexpected changes in requirements. Planning ahead helps with 'known unknowns', but it's impractical when building across N years in a dynamic environment - "knowing ahead of time" is just not possible for anything non-trivial. You need strong architectural primitives that don't scale based on developers' omniscience.
For example, say you have a workflow where, when someone signs up, you generate a user label, e.g., `$firstName $lastName`. You decide to move that to a function that might consider their personal title, preferred name, etc. Currently, it's a pure sync operation. Then, you discover people don't fill out that form at all, but some log in with Google, and you can use the name there as a fallback. Under flexible, this change is local: you can put that into your `createUserLabel(userInfo)`, and it can decide to fire off an API call to fill in any missing data, etc. In Promises land, this would taint the entire tree of everything everywhere that called that method, and all of those things must evolve or be refactored. In an Effectful system, this (previously unplanned) change remains isolated to that one function. Multiply that by every decision over multiple years for software looking for PMF, and requiring developers to "know ahead" severely slows down your ability to evolve.
Languages with effect systems typically let callers inherit the effects of their callees (e.g., calling an async function means the caller also is async), or force them to handle the effect (e.g., spawn the async call as a task and waiting synchronously for it to finish).
Effects are just a generalization, where async/await is one particular effect.
But: The fact that an operation now does some kind of I/O, or waits for user input, or whatever else you might express using async, has an _enormous_ impact on the architecture of your program. The “virality” of async is completely a feature, because it forces you to actually deal with that change, resulting in much more robust software.
It’s “inconvenient” because the architecture of your program changed. That’s what the job is, though. Languages that don’t help you here (by hiding that you made a change with huge ramifications) make it actively harder to deliver working software, in my opinion. You get there faster, but it won’t keep working.
The problem with effects systems is that effects aren't generalisable. Every effect is unique. Sometimes they can be applied automatically and sometimes not.
You can have the compiler automatically recompile map with async to make map<async>, likewise map<pure> and map<nofail> and map<noblock> but they will not be optimal; map<async> could be parallel but isn't. And you probably want to control the amount of parallelism at each call site, which just makes it a completely different function. It's likely that you wrote map in a way that uses a loop counter and it's possible the compiler can't prove it's pure. map<abortable> is likely correct, but the compiler has absolutely no way to prove that, and other functions won't be correct if you naively make them possible to abort from outside.
It's a fundamental architecture change only if we assume async == slow (or potentially slow) which isn't always true. Otherwise you might want to run something inline that the language designer made async - such as writing a file in /tmp.
That’s a good example. I would refactor that code to have a different signature and change all callers to fetch the relevant data.
I’d probably insist on doing that even in a blocking language where it’s not necessary. Interspersing database or network requests all through a codebase is horrible. Before you know it, someone is calling that function in a loop and you’re doing N serialised database queries. And you can’t even tell that that’s happening from the function signature. Your program just gets slow as your database grows. To say nothing of the correctness problems from issuing these queries outside of a transaction.
I worked on a project that was written like this in Python. The code was packed full of “convenient” sql queries. Some http requests took seconds to render. Turns out those request handlers were issuing thousands of individual sql queries, loading hundreds of megabytes from our database. A lot of the queries were redundant. The backend was just overfetching the same data over and over in tiny helper functions. Because of how the code was written, fixing performance required huge refactors all over the codebase.
File, network and database queries should not be spread all over “for convenience”. Fetching user data and processing it are different tasks. They generally shouldn’t be combined into a single function.
The solution to this is assume an async spine to your program, and branch off to as much sync code as possible. It's the same lesson you learn wrangling the IO monad in Haskell, or dependencies (like databases) in OO-land.
To go with this, don't be afraid of changing your code. If you get an unexpected change that means a whole hierarchy has to become async, bite the bullet and change the hierarchy. Such things happen.
This works for all applications, but not libraries where you don't control your callers. In that case it may make sense to make something async pre-emptively if you think requirements might change in a way that requires it but you can never predict every change successfully and you might need to make a V2 library.
LLMs are good at refactoring. So function color mismatch is no longer a problem while explicit io helps to read and understand the code.
I'm curious about your mental model. Would it be accurate to say that the async tree is the "IO program" and the sync functions operate on pure data, or is it more complicated than that?
Yeah, more or less. I think Haskell programmers are right on this.
No, that doesn't make sense at all! You're being too reductionist...
I/O has its place in every real world program, and the true limitation (or what you call a "problem") are the single-threaded runtimes of JavaScript. It's not a question of whether you should mark your functions async or not, as if it's an issue of consistency in the call-tree of your program. The true question should rather be whether your functions are I/O-bound (and would actually block the single-threaded event loop) or are solely compute-bound.
You're forgetting the fact that async/await in JavaScript was a historical design choice to prevent the callback-hell that came with single-threaded concurrency. So if you'd want to get back to that callback-hell (and convert async functions back to "some-form-of" sync), you still can[0]:
You can think of async/await as an evolution of generators[1], as every await yields control back to the event loop. I'd actually encourage you to write some of your programs' behavior with generators if you've never done that before. Generator functions will yield control back to the caller, which is an interesting way to design programs when the caller is you instead of the event loop.It's in Python, but David Beazley still has one of the best explanations on that topic, and shows the how and why you'd want to design an event loop live on stage[2].
[0]: https://www.typescriptlang.org/play/?#code/PTAEGcHsFsFNQCYFd...
[1]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guid...
[2]: https://www.youtube.com/watch?v=MCs5OvhV9S4