Asynchronous JavaScript (JS) Demystified
In this article, we will look under the hood to understand how asynchronous function is executed in JavaScript. We will explore concepts such as call stack, event loop, and message queue which are the key players behind asynchronous JS.
JavaScript is a single-threaded programming language - a language with one single call stack and a single memory heap. What it means is that the JavaScript engine can only process one statement at a time in a single thread.
Although single-threaded languages offer some levels of simplicity since developers don't have to worry about concurrency, applications coded in single-threaded programming languages face challenges with long operations (such as network access) blocking the main thread. For example, imagine what it feels like when the web page is unresponsive even just for a few seconds after you clicked a button to request some data from the API. It would be annoying, would it?😉
That's where asynchronous JavaScript comes into play. Using asynchronous JavaScript (callbacks, promises, async/await), we can perform long network requests without blocking the main thread. But how?🤔
Before we dive into asynchronous JS, let's first try to understand how its counterpart, synchronous code, gets executed inside the JS engine by looking at some simple codes.
How is synchronous code executed by the JS engine?
const second = () => {
console.log('Hello there!');
}
const first = () => {
console.log('Hi there!');
second();
console.log('The End');
}
first();
When the above code executes, the following outputs are logged in the console:
Hi there!
Hello there!
The End
To understand how the above code executes inside the JS engine, we have to understand the concept of execution context and the call stack.
Execution Context
An execution context is an abstract concept of an environment where the JS code is evaluated and executed. Whenever any code is run in JS, it's run inside an execution context.
By environment, we mean the value of this
, variables
, objects
, and functions
JS code has access to at a particular time.
There are three types of execution context in JS:
- Global execution context:
This is the default execution context in which JS code starts its execution when the file first loads in the browser. All global code, i.e. code that is not inside any function or object, is executed inside the global execution context. - Functional execution context:
This is the execution context created by the JS engine whenever it finds a function call. Each function has its own execution context. Functional execution context has access to all the code of the global execution context but not vice versa. - Eval:
Execution context insideeval
function.
Call Stack
The call stack is a stack with a Last In First Out (LIFO) structure, which is used to store all the execution context created during code execution. The LIFO structure implies that the items can be added or removed from the top of the stack only. Let's use the example code above to illustrate what this really means.
- When the code is executed, a global execution context is created represented by the
main()
method and pushed to the top of the call stack. - When a call to
first()
is encountered, it's pushed to the top of the stack. - Since
console.log('Hi there!')
is called from within thefirst()
method, it's pushed to the top of the stack, and the "Hi there!" message is logged to the console. Once finished, it's popped off the stack. - Next, we call
second()
, so thesecond()
function is pushed to the top of the stack. - Since
second()
callsconsole.log('Hello there!')
, it's pushed to the top of the stack, and the "Hello there!" message is logged to the console. Once finished, it's popped off the stack followed by thesecond()
function. - The last thing that remains in the
first()
function is the call toconsole.log('The End')
, so it's pushed to the top of the stack, and the "The End" message is logged to the console. Once finished, it's popped off the stack. - Since there is nothing left inside the
first()
function, it's popped off the stack followed bymain()
.
How is asynchronous code executed by the JS engine?
Now that we know how synchronous code executes, let's look at how asynchronous code executes.
As mentioned above, network requests take time. Depending upon the situation, the server might take some time to process the request while blocking the main thread making the web page unresponsive. The solution to this problem is to use asynchronous callbacks to make out code non-blocking. An example of an asynchronous callback function is shown below. Here, we used the setTimeout
method (available from the Web API in browsers) to simulate a network request.
const networkRequest = () => {
setTimeout(() => {
console.log('Async Code');
}, 2000);
};
console.log('Hello World');
networkRequest();
console.log('The End');
When the above code executes, the following messages are logged to the console:
Hello World
The End
Async Code
So, it seems that the call to networkRequest()
did not block our thread after all. But we said earlier, that JavaScript is a single-threaded language, so is that even possible?🤔 To understand how this code is executed, we need to understand a few more concepts such as event loop and message/task queue.
JavaScript has a concurrency model based on event loop, which is responsible for executing the code, collecting and processing events, and executing queue sub-tasks.
Message Queue
A JavaScript runtime uses a message queue, which is a list of messages to be processed. Each message has an associated function that gets called in order to handle the message.
At some point during the event loop when the call stack is empty, the runtime starts handling the messages on the queue, starting with the oldest one. The message is removed from the queue and its corresponding function is called. This process repeats every time the event loop detects that the call stack is empty indicating the next message in the queue (if available) can be processed.
ES6 introduced the concept of job queue/micro-task queue, which is used by Promises in JS. The difference between the message queue and the job queue is that the job queue has a higher priority than the message queue, which means that promise jobs inside the job queue/micro-task queue will be executed before the callbacks inside the message queue.
Event Loop
The event loop got its name because of how it's usually implemented, which usually resembles:
while (queue.waitForMessage()) {
queue.processNextMessage()
}
The job of the event loop is to look into the call stack and determine if the call stack is empty or not. If it's empty, it looks into the message queue to see if there's any pending callback waiting to be executed. Each message is processed completely before another message is processed.
In web browsers, messages are added anytime an event occurs and there is an event listener attached to it.
The event loop, message queue, and web APIs are not part of the JS engine. They are part of the browser's JS runtime environment Node.js runtime environment in the case of Nodejs.
With all of that out of the way, let's revisit our example of asynchronous callback and dissect it.
- When the above code loads in the browser, the
console.log('Hello World')
is pushed to the stack, and the "Hello World" message is logged to the console. Once finished, it's popped off the stack.
- Next, the
networkRequest()
is called, so it's pushed to the top of the stack.
SincesetTimeout()
is called from withinnetworkRequest()
, it's pushed to the top of the stack. This method takes two arguments: a time inms
and a callback function that is to be executed once the timer expires. ThesetTimeout()
method starts a timer of 2s in the web API environment.
- At this point, the
setTimeout()
has finished and is popped off the stack.
Next, theconsole.log('The End')
is pushed to the stack, and the "The End" message is logged to the console, after which the function is popped off the stack.
- Meanwhile, the timer has expired, and the callback is pushed to the message queue. At this point, since the call stack is empty, the event loop pushes the callback in the queue to the top of the call stack. Since the callback calls
console.log('Async Code')
, it's pushed to the top of the stack. The "Async Code" message is logged to the console before it's popped off the stack.
- Since the callback is finished, it's also popped off the stack and the program finally finishes.
That's it. I hope that by now, asynchronous function call in JS is no longer a mystery to you.😉