JavaScript Runtime Environment and The Event Loop

JavaScript Runtime Environment and The Event Loop

Concurrency in JavaScript

When running JavaScript in a browser, it may appear that JavaScript is multi-threaded, but it's not. JavaScript is a single-threaded programming language, which means it has a single call stack and can only execute one task at a time. When a script is running, it blocks other scripts from running until it completes.

So, why can it appear that JavaScript is multi-threaded?

The answer lies in the JavaScript Runtime Environment.

JavaScript Engine

Let's start by taking a look at the JavaScript engine. The JavaScript engine is responsible for executing JavaScript code. It consists of two main components:

  1. Call Stack: The call stack is a data structure that stores the execution context of the running code. It follows the Last In, First Out (LIFO) principle, meaning that the last function added to the stack is the first one to be executed.

  2. Heap: The heap is a memory space where objects are stored. And to remind you, arrays are also objects in JavaScript.

Let's take a look at a simple example to understand how the call stack works:

function bark() {
  console.log("Woof!");
}

function meow() {
  console.log("Meow!");
}

function speak() {
  console.log("Speaking");
  bark();
  meow();
  console.log("Done speaking");
}

speak();
  1. speak function is called, and it's added to the call stack.

  2. speak's first console log is added to the call stack and executed. When the console log is executed, it's removed from the call stack.

  3. bark function is called and added to the call stack.

  4. bark's console log is added to the call stack and executed.

  5. bark is removed from the call stack.

  6. meow function is called and added to the call stack.

  7. meow's console log is added to the call stack and executed.

  8. meow is removed from the call stack.

  9. speak's last console log is added to the call stack and executed.

  10. speak is removed from the call stack.

That's the entire process when calling the speak function.

JavaScript Runtime Environment

Assume we want to run setTimeout(foo, 500), what would happen if we pushed it to the call stack?

function foo() {
  console.log("Hello");
}

setTimeout(foo, 500);

If we pushed setTimeout(foo, 500) to the call stack, it would block the call stack for 500 milliseconds. This is not what we want. Instead, we want to run foo after 500 milliseconds. But how do we keep track of when to run foo if we can't use the call stack?

The JavaScript Engine isn't running code in complete isolation. It's running it in what we call a JavaScript Runtime Environment. This environment provides a set of extra functionality on top of JavaScript called Web APIs. These APIs include:

  • Timers (setTimeout, setInterval)

  • HTTP requests (fetch)

  • DOM manipulation functions

When we call setTimeout(foo, 500), the setTimeout function is pushed to the call stack. The setTimeout function is then removed from the call stack and sent to the Web API environment to handle the timer. But how does the JavaScript Engine know when to run foo?

This is where the callback queue comes in. The callback queue is a FIFO (First In, First Out) data structure that stores callback functions. Once the timer is complete, the Web API environment pushes the callback function (foo) to the callback queue.

The event loop is responsible for checking the call stack and callback queue. If the call stack is empty, it pushes the first function in the callback queue to the call stack. Once that's done, it checks the callback queue again for the next function. This process continues until the callback queue is empty.

The process of the Event Loop:

  1. Dequeue the first function in the callback queue.

  2. Enqueue the function to the call stack.

  3. Execute the function.

  4. Render any changes to the DOM.

  5. Remove the function from the call stack.

  6. Repeat the process until the callback queue is empty.

setTimeout(func, 0)

You might think that setTimeout(func, 0) runs the function immediately, but that's not the case.

As we mentioned earlier, setTimeout is a part of the Web API environment. When you call setTimeout(func, 0), the function is sent to the Web API environment to handle the timer. The timer is set to 0 milliseconds, but it doesn't mean the function will run immediately. The function is still sent to the callback queue, and the event loop will run it when the call stack is empty.

Let's look at some code:

console.log("Start");

setTimeout(() => {
  console.log("Inside setTimeout");
}, 0);

console.log("End");
  1. console.log("Start") is added to the call stack and executed.

  2. setTimeout is added to the call stack and sent to the Web API environment to handle the timer.

  3. console.log("End") is added to the call stack and executed.

  4. The event loop checks the call stack and callback queue. Since the call stack is empty, it dequeues the function from the callback queue and adds it to the call stack.

  5. console.log("Inside setTimeout") is added to the call stack and executed.

Microtask Queue

When promises were added to JavaScript, they introduced a new queue called the microtask queue. The microtask queue has a higher priority than the callback queue. When a promise is resolved or rejected, the callback function is added to the microtask queue.

Let's look at some code to understand how everything works together:

console.log("Start");

setTimeout(() => {
  console.log("Inside setTimeout");
}, 0);

Promise.resolve().then(() => {
  console.log("Inside Promise");
});

console.log("End");

Just to remind you, whenever something is "executed" in the call stack, it's removed from the call stack.

  1. console.log("Start") is added to the call stack and executed.

  2. setTimeout is added to the call stack and sent to the Web API environment to handle the timer.

  3. Promise.resolve().then is added to the call stack and executed. This schedules for the then callback to run after the promise is resolved. The callback console.log("Inside Promise") goes straight into the microtask queue because promises send their callbacks there.

  4. console.log("End") is added to the call stack and executed.

  5. The event loop checks the call stack, microtask queue, and callback queue. Since the call stack is empty, it dequeues the function from the microtask queue and adds it to the call stack.

  6. console.log("Inside Promise") is added to the call stack and executed.

  7. The event loop does its job again and sees that microtask queue is empty. It then dequeues the function from the callback queue and adds it to the call stack.

  8. console.log("Inside setTimeout") is added to the call stack and executed.

Pseudo code of the event loop may look something like:

while (true) {
  if (callStack.isEmpty()) {
    if (!microtaskQueue.isEmpty()) {
      callStack.push(microtaskQueue.dequeue());
    } else if (!callbackQueue.isEmpty()) {
      callStack.push(callbackQueue.dequeue());
    }
  } else {
    // This would execute the last function added to the call stack
    // And then remove it from the call stack
    callStack.execute();
  }
}

Quiz

What's the output of the following code?

console.log("Start");

Promise.resolve().then(() => {
  console.log("Inside Promise 1");
});

setTimeout(() => {
  console.log("Inside setTimeout 1");
}, 0);

Promise.resolve().then(() => {
  console.log("Inside Promise 2");
});

setTimeout(() => {
  console.log("Inside setTimeout 2");
}, 0);

console.log("End");