← back to home

Understand How JavaScript Actually Runs in Your Browser

By ,
6 min read

backend-masteryjs-internals

As a JavaScript developer, you might have wondered, “How does the browser run the code I write?” I certainly have. Let me guide you through what I've learned about this fascinating process, focusing on how JavaScript works within the browser, especially in Chromium-based browsers that utilize the V8 engine. The principles apply broadly across different engines.

JavaScript is a language that needs an engine to execute it, but that’s just part of the story. When your code runs, it first goes to the JavaScript engine, which is V8 in this case. The engine processes your code in two main phases: the Memory Phase and the Execution Phase.

In the Memory Phase, the engine scans your entire code, looking for declared variables and functions. For variables, it registers them with an undefined value. For functions, it stores the entire function declaration. This mechanism explains hoisting, which allows access to variables before they appear in your code.

Next is the Execution Phase, where your code truly comes to life. This is when variables receive their actual values, and operations start executing. But how does execution actually happen? This is where things get interesting.

When execution begins, a Global Execution Context (GEC) is created. This serves as a container for all the code that will run. The GEC is pushed onto the Call Stack. The engine then starts processing the code line by line from the context at the top of the stack. Mathematical operations, string manipulations, and other core tasks take place directly here.

What happens when the engine encounters a function call? A new execution context is created specifically for that function, and it gets pushed onto the call stack. While this new function context runs, the execution of the GEC pauses. If that function calls another function, the process repeats—another context is created and stacked on top. When a function completes, its context is popped off the stack, and execution resumes where it left off. This continues until all code runs and the stack is empty.

JavaScript isn’t just about simple operations and function calls. We often fetch data from APIs, print to the console, or run code after delays using setTimeout. These features don’t come solely from the JavaScript engine. They rely on Web APIs, which are special functions provided by the browser that extend JavaScript beyond its basic capabilities.

Let me show you how this all comes together with some practical examples:


console.log("start")
setTimeout(function cb() {
    console.log("callback function")
}, 3000)
console.log("end")

Here’s what happens when this code runs:

First, a Global Execution Context is created and pushed onto the call stack. The engine starts executing line by line. When it reaches console.log("start"), it knows it needs help from Web APIs, not just core JavaScript. Through the global window object, which provides access to browser features, the engine asks the Console API to print “start.” We usually write console.log() rather than window.console.log(), but both mean the same thing.

Next, the engine hits setTimeout. This needs help from a Web API too. The engine passes the callback function cb and a 3000ms delay to the Timer Web API. While the browser counts down, JavaScript execution keeps going—it doesn’t wait for the timer to finish.

The engine then executes console.log("end"), which prints “end” as before. The GEC completes, and the call stack empties.

Meanwhile, the timer in the Web API continues to run. After three seconds, it finishes, and the callback function cb needs to run. But it doesn’t just jump into the call stack—instead, it goes to the Callback Queue.

This is where the Event Loop comes in. The Event Loop continuously checks two things: if the call stack is empty, and if there are functions waiting in the queues. When the call stack is empty, the Event Loop takes cb from the Callback Queue and pushes it onto the call stack. A new execution context is created for cb, and “callback function” gets printed to the console.

console.log("start")
document.getElementById("btn").addEventListener("click", function cb() {
    console.log("button clicked")
})
console.log("end")

This example shows how user interactions are managed. After printing “start”, the engine registers the click event handler with the DOM API, which is another Web API. The browser now watches for clicks on that button while JavaScript continues, printing “end” immediately.

When a user clicks the button, the DOM API notices the action and places the callback function cb in the Callback Queue. The Event Loop, noticing an empty call stack, moves cb to the stack for execution, causing “button clicked” to appear in the console. This setup ensures that user interactions don’t block your main JavaScript execution thread.


console.log("start");
setTimeout(function cCB() {
    console.log("set timeout callback");
}, 0)
fetch("https://jsonplaceholder.typicode.com/todos/1")
    .then(function mCB(data) {
        console.log("fetch callback ")
    });
console.log("end");

This example reveals an important detail about JavaScript’s execution model. Here’s how it plays out:

“start” gets printed. When the engine reaches setTimeout with a 0ms delay, this callback doesn’t run right away. The Timer Web API takes the callback cCB and, after the briefest delay, places it in the Callback Queue.

Next, it processes the fetch call. This is interesting because fetch involves promises. When the fetch request is made, the Network Web API handles it. Once the response is ready, the promise callback mCB doesn’t go to the Callback Queue—it goes to the Microtask Queue.

“end” gets printed, and the GEC finishes.

Now the Event Loop goes into action, but here’s the key point: the Event Loop always prioritizes the Microtask Queue over the Callback Queue. Even though setTimeout was queued first with a 0ms delay, the fetch callback in the Microtask Queue gets handled first. The Event Loop sees mCB in the Microtask Queue, pushes it to the call stack, and “fetch callback” gets printed.

Only after the Microtask Queue is empty does the Event Loop check the Callback Queue. It finds cCB, pushes it to the call stack, and “set timeout callback” gets printed, even though its timer finished first.

This priority system explains why promise callbacks always run before setTimeout callbacks, even with zero delay. The Microtask Queue is treated differently because it handles promise resolutions, mutation observers, and other high-priority tasks, while the Callback Queue takes care of timer callbacks, DOM events, and other standard tasks.

So when you write JavaScript, remember that a complex process happens behind the scenes. The engine manages execution contexts and the call stack. Web APIs extend JavaScript’s capabilities, queues hold callbacks ready to run, and the Event Loop organizes it all, prioritizing microtasks to keep your code running smoothly while maintaining JavaScript’s single-threaded nature.

But here’s something to think about: This whole explanation applies to how JavaScript runs in the browser. What about Node.js, where there’s no browser environment? The story changes a lot—there’s no DOM API, no document object, and the event loop implementation is different. Instead of Web APIs, Node.js has its own set of C++ APIs via the libuv library. We’ll explore that intriguing world in our next blog post, where we’ll look into how JavaScript operates outside the browser and why understanding both environments makes you a more well-rounded developer.

17 views