Learnitweb

How Asynchronous JavaScript Code Is Executed Behind the Scenes

When we write JavaScript code, it may look simple and linear on the surface, but a lot is happening behind the scenes. Every browser and runtime environment uses a JavaScript engine to execute code, and understanding how this engine works gives you a much deeper insight into asynchronous behavior, callbacks, and performance issues.

Almost every modern browser has its own JavaScript engine, and while their internal implementations differ slightly, the core execution model remains the same. In this tutorial, we will focus on V8, the JavaScript engine used by Google Chrome and Node.js.

The Two Fundamental Data Structures in JavaScript Execution

At the heart of JavaScript execution are two critical data structures and one background process. Together, they define how synchronous and asynchronous code runs.

1. Call Stack

The call stack is responsible for tracking which function is currently being executed and what should happen next.

  • Every time a function starts executing, JavaScript creates an execution context for that function.
    This execution context contains the function’s local variables, arguments, and the current line of execution.
  • That execution context is then pushed onto the top of the call stack, making it the active context.
  • When the function finishes execution and returns, its execution context is removed (popped) from the call stack, and control returns to the previous context.

The call stack always works in a Last In, First Out (LIFO) manner, which is why deeply nested or recursive calls can sometimes cause stack overflow errors.

2. Message Queue (Task Queue)

The message queue (also called the task queue) is where asynchronous callbacks wait before they are executed.

  • Each entry in the message queue represents a task, and each task holds a reference to a function that must be executed later.
  • Tasks can be added to this queue in several ways:
    • In Node.js, asynchronous operations such as reading a file (fs.readFile) place callbacks into the queue once the operation completes.
    • In browsers, tasks are added whenever events occur, such as clicks, key presses, or network responses.
    • Timer functions like setTimeout also schedule callbacks by adding tasks to this queue after the specified delay.

A crucial rule to remember is that each task is processed completely before the next one begins. JavaScript never partially executes a task and then switches to another one.

The Event Loop: The Silent Coordinator

The event loop is a background process that continuously coordinates between the call stack and the message queue.

  • It constantly checks whether the call stack is empty.
  • If the call stack is empty, it then checks whether there are pending tasks in the message queue.
  • If a task is found, the event loop moves that task’s callback into the call stack by creating a new execution context.

This is why JavaScript can handle asynchronous code while still being single-threaded.

Step-by-Step Execution Example

Let’s now walk through a simplified example involving four function calls and one asynchronous operation. The goal here is not the code itself, but understanding how execution flows internally. This is from the earlier code example.

Step 1: Global Execution Context

JavaScript begins execution from the first line of the script.

  • A global execution context is created.
  • This context is placed at the bottom of the call stack.
  • Function declarations are scanned and registered, but not executed yet.

At this point, the call stack contains only the global execution context.

Step 2: Executing printOne()

When JavaScript encounters the call to printOne():

  • A new execution context for printOne is created.
  • This context is pushed onto the call stack.
  • The function executes and completes its work.
  • Once finished, its execution context is removed from the stack.

Execution now returns to the global context.

Step 3: Executing printTwo() and a Nested Function Call

Next, JavaScript steps into printTwo().

  • A new execution context for printTwo is created and pushed onto the call stack.
  • Inside printTwo, another function (getNumberTwo) is invoked.
  • JavaScript creates a separate execution context for this nested function and pushes it onto the stack.

After getNumberTwo returns:

  • Its execution context is removed.
  • Execution resumes inside printTwo.
  • Once printTwo completes, its execution context is also removed.

This illustrates how the call stack grows and shrinks dynamically.

Step 4: Executing printThree() and an Asynchronous Call

Now JavaScript moves to printThree().

  • A new execution context is created for this function.
  • Inside printThree, an asynchronous operation such as readFile() is invoked.

Here is the key point:

  • Since readFile() is asynchronous, JavaScript does not wait for it to complete.
  • Instead, it initiates the operation and immediately continues execution.
  • printThree() finishes, and its execution context is removed from the call stack.

Step 5: Executing printFour()

JavaScript now enters printFour().

  • A new execution context is created and placed on the call stack.
  • The function executes fully.
  • Once completed, its execution context is removed.

At this moment, all synchronous code has finished executing.

Final State and Program Termination

After the callback finishes:

  • JavaScript checks the call stack and finds it empty.
  • It checks the message queue and finds it empty as well.
  • With no more work left to do, the JavaScript engine exits.
 ┌──────────────────────────────────────────────────────────────┐
 │                        JAVASCRIPT ENGINE                      │
 │                                                              │
 │   ┌───────────────────────────┐     ┌─────────────────────┐ │
 │   │        CALL STACK          │     │     MESSAGE QUEUE    │ │
 │   │   (Execution Contexts)     │     │   (Async Tasks)     │ │
 │   │                           │     │                     │ │
 │   │   ┌───────────────────┐   │     │   ┌──────────────┐  │ │
 │   │   │ Function Context  │   │     │   │   Callback   │  │ │
 │   │   ├───────────────────┤   │     │   ├──────────────┤  │ │
 │   │   │ Function Context  │   │     │   │   Callback   │  │ │
 │   │   ├───────────────────┤   │     │   ├──────────────┤  │ │
 │   │   │ Global Context    │   │     │   │     ...      │  │ │
 │   │   └───────────────────┘   │     │   └──────────────┘  │ │
 │   └───────────────────────────┘     └─────────────────────┘ │
 │              ▲                               │               │
 │              │                               │               │
 │              │      EVENT LOOP (Background)  │               │
 │              │      ──────────────────────▶ │               │
 │              │   checks if stack is empty    │               │
 │              │   moves next task if yes      │               │
 │              │                               │               │
 │              └───────────────────────────────┘               │
 │                                                              │
 └──────────────────────────────────────────────────────────────┘