Telerik blogs

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:

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

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

  3. 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 function sayName has not been invoked.

  4. Next, the function greeting gets assigned a space in the variable environment, and its value is also set to the entire function body.

  5. 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 function greeting 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.

  6. 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 value undefined. (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 with let and const get initialized with uninitialized, and variables declared with the var keyword get initialized with undefined.

  7. 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 variable myName is assigned the result of a function call, so the function sayName gets invoked and pushed to the call stack.

  8. The function sayName accepts name as a parameter, so the variable name is assigned a space in memory, and its value is set to undefined. On the next line, we have a return statement indicating the end of the function. The variable name will be assigned the value Ifeoma, the value is returned from the function, and sayName 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 the name variable returned from sayName to the variable myName. We have a console.log statement on the next line. An execution context is created and pushed to the call stack; it prints Hello Ifeoma to the console. This is the end of the greeting function, so it gets popped off the call stack.

  9. 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:

  1. A global execution context will be created and added to the call stack.

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

  3. 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. Because setTimeout is a Web API, the Web API will register the callback function passed to setTimeout in the API environment and trigger the timer in the browser for 3000ms, Then setTimeout is popped off the call stack.

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

  5. In the Web API environment, we still have the callback function passed to setTimeout, waiting for the timer to expire after 3000 milliseconds.

  6. 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 for setTimeout is moved to the call stack.

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

  8. We have console.log("second") inside the callback function. The statement is added to the call stack, and second 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.


Ifeoma-Imoh
About the Author

Ifeoma Imoh

Ifeoma Imoh is a software developer and technical writer who is in love with all things JavaScript. Find her on Twitter or YouTube.

Related Posts

Comments

Comments are disabled in preview mode.