How Asynchronous JavaScript Works

Vinay Sripath S's avatar

Vinay Sripath S

javascript_meme

A thread is a sequential flow of control within a program, and multi-threading is the execution of multiple flows of control within a program.

On the contrary, single-threaded processes contain the instructions to be executed in a single sequence. This means that a line of code will be executed only after the lines before it have finished execution.

That being said, a language may support multi-threaded processes like Java or may not support it like JavaScript. Yes, JavaScript is a single-threaded language! And while this may be known to many, it does raise doubts and brings us to the question

Why is JavaScript single-threaded?

JavaScript is a single-threaded language because running code on a single thread avoids complicated scenarios and concurrency issues that arise in a multi-threaded environment. The JavaScript interpreter in the browser is a single thread, and modern browsers simply separate multiple components into separate processes managed by the same interpreter. No browser will allow the JavaScript of a single page to run concurrently. However, JavaScript does support asynchronous functions like the setTimeOut function, and while this allows some sort of scheduling, fake concurrency, and starting and stopping of threads, it is not true multi-threading.

How does JavaScript handle asynchronous functions, Why doesn't code execution STOP?

Asynchronous functions are functions that are not executed in any order or are executed simultaneously. These functions execute separately and do not block code execution on the main thread. They allow us to run long-running tasks on the background, and still be able to respond to other inputs while the task runs. This is usually achieved by using multiple threads to run different tasks. Multi-threading is a well-known approach to achieve asynchronous programming, but it is not supported by JavaScript.

Though JavaScript is a single-threaded language, it supports asynchronous functions and depicts a non-blocking behaviour. To understand how JavaScript does this, it is essential to understand the JavaScript runtime.

js-runtime

The JavaScript runtime comprises a JavaScript engine, Web APIs, and a Callback queue. The JavaScript engine has a heap where objects are stored and a call stack where our code is executed.

How the Call Stack helps in executing synchronous code

For every function in JavaScript, an execution context is created. The execution context of a function is nothing but the complete environment required by that function to execute. It consists of a variable environment where all variables and function declarations are stored. The variable environment also consists of a scope chain, an arguments object, and the function's this keyword. Note: Arrow functions do not get the arguments object and this keyword.

When our code begins execution, a global execution context is loaded to the call stack which initialises all our global variables and functions. Then, everytime a function is called, its execution context is loaded to the call stack and execution starts. When the function has finished execution, it is popped from the call stack and the execution resumes in the calling function. This continues till all functions have been executed and popped from the call stack.

This can be seen in the code snippet shown below

const secondfunc = () => {
  console.log('in 2nd');
};
const firstfunc = () => {
  console.log('in 1st');
  secondfunc();
  console.log('end');
};
firstfunc();

The image below shows the call stack for the code.

javascript_call_stack

When code execution starts, a global execution context (ec) is created and loaded onto the call stack. Then the firstfunc() is loaded onto the call stack.

console.log('in 1st') is loaded, and when it has finished execution, it is removed and the secondfunc() is loaded onto the call stack.

console.log('in 2nd') is loaded, and when it has finished execution, it is removed from the call stack. With this, secondfunc() has also finished execution and is removed from the call stack. console.log('end') is then loaded, executed, and removed from the call stack.

At this point the firstfunc() has also finished execution and is removed from the call stack. With firstfunc() removed, the program has completed execution, and the global execution context is removed from the call stack.

Asynchronous functions and the Event Loop

When an asynchronous function is loaded to the call stack, it is executed and starts a task outside of the JavaScript engine in the Web API. At this point, the asynchronous function has completed execution and is immediately popped from the call stack. Since it has been popped from the call stack, it does not block code execution. When the async function returns and is ready to be executed, it is placed in the callback queue. The callback queue is like a data structure that holds all the callbacks that are going to be executed.

The Event Loop checks the call stack, and when the call stack is empty, it loads the first callback from the callback queue to the call stack. Hence making asynchronous functions and non-blocking behaviour possible in JavaScript.

console.log('start');
setTimeout(() => {
  console.log(`understand asynchronous javascript 2`);
}, 5000);
setTimeout(() => {
  console.log(`understand asynchronous javascript 1`);
}, 0);
console.log('end');

The output for the code snippet shown above would be

start
end
understand asynchronous javascript 1
understand asynchronous javascript 2

Below is a representation of how this code is handled by the JavaScript runtime. Showing how the call stack and event loop work together to make asynchronous functions possible in JavaScript.

javascript_eventloop_gif

When execution begins, "start" will be logged to the console. Now as we have used setTimeout in the second line , which is an asynchronous function, it is immediately moved to the Web APIs to complete its execution. Same with the second setTimeout, which will be handled similarly in the Web API, and those two functions will not block the other lines of code hence making the flow synchronous. Then "end" will be logged to the console.

When the setTimeout has finished running, it returns a callback function which is ready to be executed. This callback is stored in the callback queue.

Now, since there is nothing left to execute on the call stack, the event loop will load the setTimeout which returned first to the callback queue. In our case the setTimeout with 0 secs will be loaded first onto the call stack and then setTimeout with 5 secs will be executed.

Promises and the Microtasks Queue

In JavaScript, the results of all asynchronous functions and DOM events are pushed to the callback queue. However, the results of promises are added to a different queue called the mircotasks queue.

Promises are capable of handling a sequence of asynchronous operations. A promise may return another promise and can be chained. The microtask queue facilitates continuing asynchronous program operation as soon as possible after handling the completion of one step in a sequence of asynchronous operations.

In the case of promises, there is no need to wait for something to happen before proceeding to the next step in a promise chain. The microtask queue ensures that the next promise handler is executed asynchronously, with a clean stack and almost immediately. It is for the same reason that the microtasks queue is given precedence over the callback queue.

The Event loop first checks the microtasks queue. If the microtasks queue contains any pending tasks, they are pushed to the call stack. Callbacks from the call back queue are loaded only when the microtask queue is empty. If the microtask queue is never empty, the callbacks in the callback queue will never execute.

console.log('start');
setTimeout(() => {
  console.log('setTimeout function executed');
}, 0);
new Promise((resolve, reject) => {
  resolve('Promise resolved');
})
  .then((res) => console.log(res))
  .catch((err) => console.log(err));
console.log('end');

The output for the code snippet shown above would be

start
end
Promise resolved
setTimeout function executed

We can see that the promise is executed before the setTimeout() even if the setTimeout() has 0 secs and is ready to be executed. This is because the Promise which belongs to the microtask queue is given precedence over the callback queue.

microtask_queue

Conclusion

We discussed how JavaScript works behind the scenes, how JavaScript is single-threaded and not truly multi-threaded, although it supports some features similar to multi-threading. We understood how the JavaScript runtime and the event loop work, and how they enable asynchronous functions in JavaScript.

While JavaScript will remain single-threaded, we cannot deny that with the increased demand for quicker rendering of graphical visualisations and larger data sets, it is transitioning into a truly multi-threaded language with a more process-based approach.

Understanding asynchronous JavaScript and how it works brings us one step closer to comprehending the language and its advanced concepts in order to meet the ever-changing needs of Web Applications.

GitHub link

Additional references

Understand the event loop and JavaScript concurrency model in greater detail

Understanding Asynchronous Programming

Other resourses to understand Asynchronous JavaScript