DEV Community

JayZho
JayZho

Posted on

Understand Async in JS: the core concepts

1. Components of JS in the Browser

When people talk about "Javascript" nowadays they are most likely refering to the combination of Javascript + Browser. Strictly speaking, "Javascript" by itself is just a programming language with an interpreter(e.g. Chrome's V8 engine) that understands Javascript's syntax and semantics and converts the JS code you write to some lower level runnable code.

You might have wondered how JS is able to do asynchronous calls while itself being a single-threaded programming language. And the fun fact is, you were right, JS actually cannot. It's the runtime environment provided by browsers like Chrome and Safari that adds this extra async feature to the original JS. Apart from that, JS runtime environment also provides handy objects like DOM and BOM, which lets you interact with the elements in the HTML and the browser respectively.

When you write something like

document.getElementById("myDog").classList.remove("bad-dog")
Enter fullscreen mode Exit fullscreen mode

you're using a feature provided by the JS runtime environment, namely, the DOM to interact with the elements inside HTML.

And when you wanna output the width of the screen and to load the previous URL in the history list, you might do something like

console.log(screen.width)
history.back()
Enter fullscreen mode Exit fullscreen mode

In this case, you're accessing the objects screen and history provided by BOM, which is another feature in the JS runtime environment.

Image description

As you can see, it's really the runtime environment provided by a web browser on top of JS that allows us to build amazing things using the language Javascript. Hence, it's essential to have the clear picture in mind that Javascript in modern browsers refers to the combination of Javascript Engine and Javascript Runtime Environment.

Note that it's not only browsers that provide runtime environment for JS, for example, node.js also provides a runtime environment for locally running JS code.

For this blog, we'll only focus on the browser side.


2. The Async Mechanism

As mentioned above, JS is a single-threaded language. Therefore, the async feature is provided by JS rutime environment in the browser. To get a clear picture of this async mechanism, let's first understand the Call Stack.
Say we have this code

fuction a() {
    console.log("a")
}

function b() {
    a()
    console.log("b")
}

b()
Enter fullscreen mode Exit fullscreen mode

The call stack of this chunk of code follows the "First in Last out" principle, meaning that the innermost function finishes first and the outermost function finishes the last:
Image description

Now we add in some async task:

function timesUp() {
    console.log("timesUp!")
}

console.log("before async")
setTimeout(timesUp, 1000)
console.log("after async")
Enter fullscreen mode Exit fullscreen mode

When the asychronous code setTimeout() is added, the async function call behaves like a normal sync function which finishes immediately and let the call stack continue.
But from this point on, the browser secretly runs the async code in parallel(in C++ for Chrome) to the synchronous JS code, and when the async task is done, the browser pushes the callback function of this async task to a data structure known as the Event Queue, which follows the "First in First out" principle. And there comes our Event Loop guy, who is constantly checking if the call stack is empty, and if yes it pops the event queue and pushes onto the call stack, then the pushed callback function from the event queue will be executed normally as a synchronous function:

Pushing console.log("before async") to the call stack
Image description

Function finishes
Image description

Pushing setTimeout() onto the call stack
Image description

Async function call finishes immediately and gets run in the background by the browser, while the call stack continues to call console.log("after async")
Image description

console.log() finishes, the timer keeps running until 1000 milliseconds has passed
Image description

Async task done, its callback function gets pushed:
Image description

Event Loop grabs the callback and pushes to the call stack:
Image description

The call stack executes as usual:
Image description

Image description

Image description

Note that in the diagrams above, the Web API, Event Queue and Event Loop are all part of the JS runtime environment, meaning that they don't belong to the JS engine but instead are powered by the browser, without which we cannot run async code in JS.

Now that we understand the mechanism behind async events, we can see that the code below doesn't just give "setTimeout" a zero delay for no reason:

setTimeout(() => console.log("call stack empty, I'm from the event queue!"), 0)
console.log("call stack busy")
Enter fullscreen mode Exit fullscreen mode

3. Promises

3.1 The motivation

Just because Promises are normally used in conjunction with async tasks, we shouldn't be tricked into thinking that by wrapping a function into a Promise, we turn it into async.(at least that's what I thought initially)

Promises are simply a syntactic sugar -- something that helps us format our code in a better way, but really it's just another way to specify(especially chain) callback functions.

Say you got an async function "asyncFunc", which takes in two callback functions, one to call on a success, the other one to call on an exception:

function asyncFunc(success_callback, failure_callback);
Enter fullscreen mode Exit fullscreen mode

The way to turn it into a Promise is simply wrapping this "asyncFunc" by another function that also takes 2 callback functions, which are named "resolve" and "reject" by convention, and passing them into "asyncFunc" accordingly:

const promisedAsyncFunc = new Promise(
    (resolve, reject) => {
        asyncFunc(resolve, reject)
    }
)
Enter fullscreen mode Exit fullscreen mode

Now we're able to specify its callbacks by append a .then() instead of nesting them into its arguments:

function successCallback() {
    console.log("yay")
}

function failureCallback() {
    console.log("oh no")
}

//traditional callback passing
asyncFunc(successCallback, failureCallback)

//using Promise
promisedAsyncFunc.then(successCallback, failureCallback)
Enter fullscreen mode Exit fullscreen mode

This might seem redundant at the moment, but imagine you also have further callbacks to pass into your callbacks, what easily turns into a "Callback Hell", something like:

setTimeout(() => {
    setTimeout(() => {
        setTimeout(() => {
            console.log("3 seconds has passed")
        }, 1000)
    }, 1000)
}, 1000)
Enter fullscreen mode Exit fullscreen mode

By wrapping setTimeout() in a Promise, we can specify its callback function by putting it in the .then() function, which can accept another .then() to specify the callback function of this callback function. (but only if the former callback function also returns a Promise):

function promisedSetTimeout(interval) {
    return new Promise((resolve, reject) => {
        setTimeout(resolve, interval)
    })
}

//now a non-recursive way for multiple layers of callbacks
promisedSetTimeout(1000)
.then(() => promisedSetTimeout(1000))
.then(() => promisedSetTimeout(1000))
.then(() => console.log("3 seconds has passed"))
Enter fullscreen mode Exit fullscreen mode

Therefore, the motivation of Promise is clear: we want to flatten the "recursiveness" of the callback hell.

3.2 Returning vs. Passing in

In comparison to returning a Promise in the passed-in function of .then(), we can also return a non-Promise data and still be able to chain it using .then().
See the below example:

function someSyncCode() {
    return new Promise((resolve, reject) => {
        console.log("do task #1")
        resolve()
    })
}

someSyncCode()
    .then(() => {
        console.log("do task #2")
        return "task #3"
    })
    .then((task) => {
        console.log("got " + task) // got task #3
    })
Enter fullscreen mode Exit fullscreen mode

Which works the same as returning a Promise in the first .then() and call resolve("task #3") instead of directly returning that string:

function someSyncCode() {
    return new Promise((resolve, reject) => {
        console.log("do task #1")
        resolve()
    })
}

someSyncCode()
    .then(() => {
        return new Promise((resolve, reject) => {
            console.log("do task #2")
            resolve("task #3")
        })
    })
    .then((task) => {
        console.log("got " + task)
    })
Enter fullscreen mode Exit fullscreen mode

This way to pipeline function calls might feel a bit counter-intuitive, especially if you've been only dealing with normal function pipeling, where a function must directly return something for the next function to use as its input. But that's exactly the power of Promise chaining, which allows us to flatten the "recursiveness" of callbacks.

In short, a .then() always expects a Promise.resolve(output) object from its previous Promise, which then gets unwrapped and output gets fed to this .then()'s passed-in function. And we can return such an object in several ways:
1.Return a new Promise() and call resolve() with data passed in:

Promise.resolve().then(() => {
    return new Promise((resolve, reject) => {
        resolve("data")
    })
})
Enter fullscreen mode Exit fullscreen mode

2.Return explicitly using Promise.resolve("data"):

Promise.resolve().then(() => {
    return Promise.resolve("data")
})
Enter fullscreen mode Exit fullscreen mode

3.Return just the data itself. Then .then() will implicitly wrap "data" with a Promise, hence it's equivalent to returning Promise.resolve("data"):

Promise.resolve().then(() => {
    return "data"
})
Enter fullscreen mode Exit fullscreen mode

Just to clarify, the second example might not feel intuitive since what we did in the first .then() is purely synchronous, making the need for passing the return value into a callback rather than immediately returning it seem unnecessary. The purpose is solely to compare the 2 ways of fulfilling a .then().

So yes, in a practical situation, we would do an async task in the first .then() if we're returning a Promise, something like:

.then(() => {
    return new Promise((resolve, reject) => {
        console.log("do task #2")
        setTimeout(() => resolve("task #3"), 1000)
    })
})
Enter fullscreen mode Exit fullscreen mode

3.3 Multi-layered Promises

The Promise-unwrapping process in .then() is recursive, meaning that no matter how many layers of Promises are wrapped around the innermost data, .then() always unwraps them all until the data itself is revealed. Therefore, the simple example below:

Promise.resolve(0).then((v) => console.log(v)) // 0
Enter fullscreen mode Exit fullscreen mode

has the same effect as this crazy multi-layered Promise example below:

Promise.resolve(
    new Promise((resolve, _) => {
        resolve(new Promise((resolve, _) => {
            resolve(0)
        }))
    })
).then((v) => console.log(v)) // 0
Enter fullscreen mode Exit fullscreen mode

3.4 Microtask Queue

I said that Promises are purely a syntactic sugar, meaning that to the underlying program, chaining callbacks with Promises is the same as directly passing callbacks to functions.

That's not exactly true. Consider the following 2 examples:

Using Promise for passing the callback function "secondTask":

function firstTask() {
    return new Promise((resolve, reject) => {
        console.log("doing the first task")
        resolve("the second task")
    })
}

function secondTask(task) {
    console.log("doing " + task)
}

function thirdTask() {
    console.log("doing the third task")
}

firstTask().then(secondTask)
thirdTask()
Enter fullscreen mode Exit fullscreen mode

Directly passing in "secondTask":

function firstTask(callback) {
    console.log("doing the first task")
    callback("the second task")
}

function secondTask(task) {
    console.log("doing " + task)
}

function thirdTask() {
    console.log("doing the third task")
}

firstTask(secondTask)
thirdTask()
Enter fullscreen mode Exit fullscreen mode

Both examples print "doing the first task" first, but the first example prints "doing the second task" last, while the second example prints "doing the third task" last.

For the second example, the execution stack is straight-forward and the order of messages generated is expected.
We now take a look at the events happened behind the first example.

1.firstTask():

The function call firstTask() gets pushed to the call stack, which returns a Promise, hence the function passed into the Promise constructor

(resolve, reject) => {
  console.log("doing the first task")
  resolve("the second task")
}
Enter fullscreen mode Exit fullscreen mode

gets executed immediately, prints "doing the first task", and resolve the Promise.

2..then(secondTask):

Since the previous Promise is resolved, the callback function secondTask now gets pushed to the Microtask Queue, waiting to be executed. Now we advance to the next line thirdTask(). Note that if there's more .then() following this .then(secondTask), they'll all get skipped and we still continue straight to thirdTask()

3.thirdTask()

We print "doing the third task".
Now the call stack is empty. The function call secondTask() gets pushed to the call stack and executed, printing "doing the second task".

The Microtask Queue, in comparison to the Event Queue(aka Callback Queue), is another queue with priority higher than the event queue. When the JS call stack gets empty, before pushing tasks from the event queue, the event loop first pushes tasks from the microtask queue to the call stack, execute them all, then grab tasks from the event queue if there's any.

Function execution in a Promise generated by .then or .catch or .finally get pushed to the microtask queue.

Below is a good example to show the execution priority of the call stack, the microtask queue and the event queue:

// executed first
console.log("one")

// executed the last
setTimeout(() => console.log("four"), 0)

// thirdly executed
Promise.resolve().then(() => console.log("three"))

// secondly executed
console.log("two")
Enter fullscreen mode Exit fullscreen mode

4. Await & Async

We can turn a function into async by append the keyword "async" the a function definition, but it doesn't really turn it into a non-blocking function, surprisingly.
Consider the example below:

async function heavyComputation() {
    for (let i = 0; i < 1000000000; i++) {}
    console.log("heavy computation done")
}

console.log("before heavy computation")
heavyComputation()
console.log("after heavy computation")
Enter fullscreen mode Exit fullscreen mode

Although we've declared that heavyComputation is async, hoping it won't block the execution of our main thread, we still have to wait for it to finish before we can run console.log("after heavy computation")

The reason is simple enough: all the "async" keyword does is:
#1: Implicitly making the function return a Promise
#2: Allowing the use of await inside the function, which is equivalent to using .then().

To put it simple, the keywords async/await are just syntax alternatives for dealing with Promises. Hence, it's not difficult to understand the behaviour of the above example, and we are able to easily understand that the two examples below are also quivalent:

function heavyComputation() {
    return new Promise((resolve, reject) => {
        for (let i = 0; i < 1000000000; i++) { }
        console.log("heavy computation done")
        resolve("computation result")
    })
}

function getResult(result) {
    console.log("got " + result)
}

heavyComputation().then(getResult)
console.log("main thread running...")
Enter fullscreen mode Exit fullscreen mode
async function heavyComputation() {
    for (let i = 0; i < 1000000000; i++) { }
    console.log("heavy computation done")
    return "computation result"
}

async function getResult() {
    let result = await heavyComputation()
    console.log("got " + result)
}

getResult()
console.log("main thread running...")
Enter fullscreen mode Exit fullscreen mode

If you're thinking that the second one looks a lot cleaner, then that's exactly the point of introducing the async/await keywords: it allows us to work with Promises in a more comfortable fashion.

Top comments (0)