This content originally appeared on Telerik Blogs and was authored by Ifeoma Imoh
In this post, we will go through a high-level overview of how synchronous and asynchronous JavaScript code gets executed by the JavaScript engine.
A JavaScript engine is a program that executes JavaScript code and converts it to a language the computer understands. Every web browser contains a JavaScript engine. For example, V8 is the JavaScript engine in Google Chrome and Node.js. Different browsers have different JavaScript engines, but they implement the same basic concept under the hood.
Before we go into more details, let’s go through some of the terms used in this post:
Execution context: An execution context is an environment where JavaScript code runs and executes. A new execution context is created whenever a function is called or invoked. We have two types of execution contexts: Global execution context and function execution context. And an execution context has two phases: memory creation and code execution.
Variable environment: A variable environment is where the JavaScript engine allocates memory in key-value pairs to the variables and functions within an execution context.
Call stack: The call stack is a part of the JavaScript engine that helps keep track of function calls. When a function gets invoked, it is pushed to the call stack where its execution begins, and when the execution is complete, the function gets popped off the call stack. It uses the concept of stacks in data structures that follows the Last-In-First-Out (LIFO) principle.
Event loop: The event loop runs indefinitely and connects the call stack, the microtask queue and the callback queue. The event loop moves asynchronous tasks from the microtask queue and the callback queue to the call stack whenever the call stack is empty.
Callback queue: Callback functions for setTimeout() are added to the callback queue before they are moved to the call stack for execution.
Microtask queue: Asynchronous callback functions for promises and mutation observers are queued in the microtask queue before they are moved to the call stack for execution.
Synchronous JavaScript
JavaScript is synchronous, blocking and single-threaded. This means that the JavaScript engine executes our program sequentially, one line at a time from top to bottom in the exact order of the statements.
Let’s say we have three console.log
statements.
console.log("One")
console.log("Two")
console.log("Three")
This will be the output:
One
Two
Three
The JavaScript engine cannot execute the second console.log
statement before the first one, and the third one can’t be executed before the second one. This is what I mean when I say JavaScript is synchronous, and it processes our script
line by line. Until a current task is completed, the next task cannot begin.
Let’s take another example:
function sayName(name){
return name;
}
function greeting(){
var myName = sayName('Ifeoma')
console.log(`Hello ${name}`)
}
greeting()
When this code runs, the following will happen:
A brand-new execution context called the global execution context will be created and pushed to the call stack. This is the main execution context, where our top-level code will be executed. Each program has just one global execution context that is always found at the bottom of the call stack.
The memory creation phase for the global execution context begins. During the memory creation phase, the variables and functions declared in this program get allocated space in memory (aka variable environment). We don’t have variables declared in the global scope, so the functions in this scope will get assigned a space in memory.
Next, the function
SayName
gets assigned a space in the variable environment, and its value is set to the entire function body. The code inside the function won’t be evaluated because the functionsayName
has not been invoked.Next, the function
greeting
gets assigned a space in the variable environment, and its value is also set to the entire function body.The function
greeting
gets invoked on the next line. Since nothing is left to be added to the variable environment, the code execution phase for the global execution context begins. A brand-new execution context for the functiongreeting
is created and pushed to the top of the call stack. Now, remember I said every execution context has two phases. For this execution context, the memory allocation phase begins.On the first line inside the function body, we have a variable called
myName
. It will be assigned a space in memory and initialized with the valueundefined
. (Note: During the memory creation phase, variables don’t get assigned their values; assignments happen in the code execution phase. During the memory creation phase, variables declared withlet
andconst
get initialized withuninitialized
, and variables declared with thevar
keyword get initialized withundefined
.On the next line, we have
console.log(`Hello ${name}`)
, and this is the end of the memory creation phase for this function, so the code execution phase begins. The variablemyName
is assigned the result of a function call, so the functionsayName
gets invoked and pushed to the call stack.The function
sayName
acceptsname
as a parameter, so the variablename
is assigned a space in memory, and its value is set toundefined
. On the next line, we have areturn
statement indicating the end of the function. The variablename
will be assigned the valueIfeoma
, the value is returned from the function, andsayName
is pushed out of the call stack.The thread of execution is now back inside the execution context for the
greeting
function. It assigns the value of thename
variable returned fromsayName
to the variablemyName
. We have aconsole.log
statement on the next line. An execution context is created and pushed to the call stack; it printsHello Ifeoma
to the console. This is the end of thegreeting
function, so it gets popped off the call stack.Now, we’re back to the global execution context. There’s nothing left to run, so it is popped off the call stack as well, and this is the end of our program.
As seen in the steps above, JavaScript requires that each step be complete before the next step can begin. This indicates that until a current task is completed, the next task will be blocked. Imagine you have a task that takes a while to complete; nothing else can happen until that task is complete, which can cause the browser to appear frozen. Let’s see how we can create asynchronous operations and how the JavaScript engine handles them.
Asynchronous JavaScript
Unlike synchronous operations, an asynchronous operation does not block the next task from commencing even if the current task isn’t complete yet. The JavaScript engine works with additional features called Web APIs (setTimeout, setInterval, etc.) in the web browser, which allows JavaScript to behave asynchronously.
With the help of these Web APIs, JavaScript can move certain tasks to the browser while JavaScript continues executing the synchronous operations. As a result of this asynchronous behavior, if we have a task that may take some time (accessing a database, file system operations, etc.), the asynchronous task can be handed off to the browser to happen in the background without blocking the next task.
In the example below, I’ll use a setTimeout()
function to demonstrate an asynchronous operation. I won’t include details on how memory gets allocated because I already explained that above.
console.log("first")
setTimeout(() => {
console.log("second");
}, 3000)
console.log("third")
When this code runs, the following will happen:
A global execution context will be created and added to the call stack.
On the first line, we have
console.log("first")
. An execution context will be created for it and pushed to the call stack,first
will be printed to the console, then popped off the call stack.On the next line, we have a
setTimeout()
function, which is one of the browser’s Web APIs. It takes two parameters: a callback function as the first parameter and the time (specified in ms) you want to wait before executing the callback function as the second parameter. A new execution context is created and pushed to the stack. BecausesetTimeout
is a Web API, the Web API will register the callback function passed tosetTimeout
in the API environment and trigger the timer in the browser for 3000ms, ThensetTimeout
is popped off the call stack.On the next line, we have
console.log("third")
. An execution context is created and added to the call stack,third
is printed in the console, and then the function is popped off the call stack.In the Web API environment, we still have the callback function passed to
setTimeout
, waiting for the timer to expire after 3000 milliseconds.Let’s say the timer is up. The callback function can’t be moved directly from the Web API environment to the call stack for its execution. It has to wait its turn, so it is first moved to the callback queue to wait until all the synchronous operations have been executed and the call stack is empty. If we had a thousand operations after the
setTimeout
function, they would all be executed before the callback function forsetTimeout
is moved to the call stack.The event loop is responsible for moving asynchronous tasks from the callback queue to the call stack whenever the call stack is empty. The call stack is empty now, so the event loop moves the callback function to the call stack for its execution, and a new execution context is created for it.
We have
console.log("second")
inside the callback function. The statement is added to the call stack, andsecond
is printed to the console, then it is popped off the call stack. Now at the top of the call stack, we have the callback function, and its execution is complete, so it gets popped off the call stack. We’re back to the global execution context, and since there’s nothing left to be executed, it also gets popped off the call stack.
Apart from the callback queue, we also have the microtask queue, which has a higher priority. Callback functions from promises and mutation observers are added to the microtask queue. When a promise is ready, the promise callback is added to the microtask queue, where it has to wait for its execution.
The event loop repeatedly moves callback functions from the microtask queue and the callback queue to the call stack, but the microtask queue has a higher priority than the callback queue. Callback functions queued in the microtask queue will all be moved to the call stack, one at a time before any callback from the callback queue moves. When the microtask queue is empty, the event loop can start moving callbacks in the callback queue to the call stack.
Conclusion
In this post, we’ve seen a high-level overview of the steps taken by the JavaScript engine to execute synchronous code and asynchronous code. We also saw how we can make JavaScript behave asynchronously by using web APIs provided by the browser. Understanding the basics of how the JavaScript engine works under the hood is fundamental for JavaScript developers who want to master the language.
This content originally appeared on Telerik Blogs and was authored by Ifeoma Imoh
Ifeoma Imoh | Sciencx (2022-12-28T09:11:01+00:00) How JavaScript Code Gets Executed: Synchronous vs. Asynchronous. Retrieved from https://www.scien.cx/2022/12/28/how-javascript-code-gets-executed-synchronous-vs-asynchronous/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.