DEV Community

Cover image for JavaScript's async function and topological ordering
Yeom suyun
Yeom suyun

Posted on • Updated on

JavaScript's async function and topological ordering

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Dag Example
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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
oculus42 profile image
Samuel Rouse

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.

Collapse
 
artxe2 profile image
Yeom suyun

Thanks for pointing that out. I've made the correction immediately.

Collapse
 
elbamm2 profile image
ElbaM

Excelent! I need practices it

Collapse
 
albertofdzm profile image
Alberto Fernandez Medina

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".

Collapse
 
artxe2 profile image
Yeom suyun

I appreciate the accurate feedback. I've made the correction immediately.