Today we all use web browsers.
And this is like saying that we all use JavaScript.
There are many reasons behind this,
but one of the main reasons may be that JavaScript supports powerful asynchronous functions.
However, crafting intricate asynchronous logic tends to be notably more challenging than devising procedural logic.
How can write asynchronous logic more easily?
JavaScript is a single-threaded language.
This is an important issue.
A single thread means that everything is done synchronously.
Let's look at the example below.
const { log } = console
async function a() {
log(2)
b()
log(5)
}
async function b() {
log(3)
log(4)
}
log(1)
a()
log(6)
What would be the output result of the above code?
It's 1, 2, 3, 4, 5, 6
.
By default, the async
keyword does not affect the execution order.
Now let's look at the second example.
const { log } = console
async function a() {
log(2)
await b()
log(5)
}
async function b() {
Promise.resolve()
.then(() => log(4))
.then(() => log(6))
}
log(1)
a()
log(3)
Similarly, the code above also prints 1, 2, 3, 4, 5, 6
.
To understand this, you need to know about the Microtask queue.
log(4)
is enqueued in the queue as soon as Promise.resolve()
is completed inside the function b
.
After that, function b
exits, and due to the await
, log(5)
is enqueued in the queue.
Finally, when function a
finishes and log(3)
is executed, after the initially queued log(4)
is executed, log(6)
is enqueued in the queue.
What happens if you add await
before Promise.resolve()
here?
const { log } = console
async function a() {
log(2)
await b()
log(6)
}
async function b() {
await Promise.resolve()
.then(() => log(4))
.then(() => log(5))
}
log(1)
a()
log(3)
The example above is mostly similar to the second example, but log(6)
is enqueued in the queue after log(5)
is completed. As a result, the code above also prints 1, 2, 3, 4, 5, 6
.
The examples above are all logic executed within JavaScript, and therefore, all operations are carried out synchronously.
External APIs for JavaScript
Then, what are the cases in which operations are performed asynchronously in JavaScript?
In today's JavaScript runtime environments, such as web browsers and Node.js, several external APIs enable asynchronous operations using multithreading. Some of these include familiar features like setTimeout
, fetch
, and HTMLElement.addEventListener
.
The following example uses setTimeout
.
const { log } = console
async function a() {
log(2)
await b()
queueMicrotask(() => log(4))
}
async function b() {
new Promise(
resolve => setTimeout(() => resolve(log(5)))
).then(() => log(6))
}
log(1)
a()
log(3)
Indeed, it's a code that prints 1, 2, 3, 4, 5, 6
.
Just to clarify, queueMicrotask
is a function that directly schedules a callback in the microtask queue.
Then why is log(5)
executed later than log(4)
?
That's because JavaScript's external APIs rely on a feature called the Event loop.
The event loop operates as a separate queue from the microtask queue, and when all microtask is complete, the next event callback is executed.
Due to this structure, even though log(5)
is enqueued in the event callback queue before queueMicrotask
or log(4)
, it is executed after log(4)
in the microtask queue has been executed.
Promise Chaining and DAG
Sure, now let's go back to the original question.
How can write asynchronous logic more easily?
The answer is to construct complex asynchronous logic into DAGs.
"DAG" stands for directed acyclic graph.
Let's consider a scenario where we use a user ID to retrieve user information and a list of posts.
Subsequently, for each post, we fetch the corresponding comments.
Once all these tasks are completed, we need to display the gathered information on the view.
Representing this procedure as a graph would look like the following
And this could be implemented as follows
async function get_user_info(user_id) {}
async function get_user_posts(user_id) {}
async function get_post_comments(post_id) {}
function render(contents) {}
async function get_contents(user_id) {
const user_info_promise = get_user_info(user_id)
const user_posts_promise = get_user_posts(user_id)
const posts_comments_promise = user_posts_promise
.then(
posts => Promise.all(
posts.map(
post => get_post_comments(post.post_id)
)
)
)
const user_info = await user_info_promise
const user_posts = await user_posts_promise
const posts_comments = await posts_comments_promise
return user_posts.map((post, i) => {
post.user_name = user_info.user_name
post.comments = posts_comments[i]
return post
})
}
const contents = await get_contents(123)
render(contents)
By appropriately utilizing Promise.all
and lazy await
, scenarios like this DAG can be easily resolved in a topological order.
However, if the composition of the DAG becomes more complex, solving the problem using this approach alone can become cumbersome.
Async Lube: Simplify Asynchronous Operations in JavaScript
Async Lube is a simple library that makes it easy to create complex asynchronous operations.
Using async-lube
, the example above could be written as follows
import { dag } from "async-lube"
/* omit */
async function get_contents(user_id) {
const get_posts_comments = posts => Promise.all(
posts.map(
post => get_post_comments(post.post_id)
)
)
const merge_contents = dag()
.add(get_user_info, user_id)
.add(get_user_posts, user_id)
.add(get_posts_comments, get_user_posts)
.add(
(user_info, user_posts, posts_comments) =>
user_posts.map((post, i) => {
post.user_name = user_info.user_name
post.comments = posts_comments[i]
return post
}),
get_user_info,
get_user_posts,
get_posts_comments
)
return merge_contents()
}
const contents = await get_contents(123)
render(contents)
The dag().add
function takes a callback
and ...dependencies
as arguments, and dag()()
executes the callbacks in the order of topological sorting.
By using this approach, the complexity of even intricate asynchronous logic can be constrained to increase linearly with the amount of code.
If you'd like to see code that uses the functioning async-lube
, an example is shown below.
Top comments (5)
Really interesting stuff. The deep dive into the micro task queue vs. event loop is a great analysis of one of the most complicated parts of asynchronicity in JavaScript.
A quick note: In the example code in Promise Chaining and DAG you have an extra closing parenthesis after
posts_comments_promise
.Thanks for pointing that out. I've made the correction immediately.
Excelent! I need practices it
Great post. Didn't know about async-lube. Just one thing to point out, not sure if in the code example implementing async-lube and dag the "user_info_promise" function should be "get_user_info".
I appreciate the accurate feedback. I've made the correction immediately.