DEV Community

Cover image for JavaScript Internals: Call Stack, Execution Context & Hoisting at a Deep Level
Patoliya Infotech
Patoliya Infotech

Posted on

JavaScript Internals: Call Stack, Execution Context & Hoisting at a Deep Level

Most JavaScript developers write code every day without ever asking: "What actually happens when my code runs?" They call functions, declare variables, and wonder why undefined mysteriously appears where they least expect it.

The answer to all of this lives inside three core JavaScript mechanisms: the call stack, the execution context, and hoisting. Once you understand these internals, the weird bugs and unexpected behaviors start to make perfect sense and your code quality jumps to a completely different level.

Whether you're building with React, Node.js, or Next.js, understanding how JavaScript processes your code under the hood is the difference between guessing and knowing.

Let's go deep.

What Is the JavaScript Engine and Why Does It Matter?

Before we get into the stack and contexts, it's worth spending 30 seconds on the environment these things live in.

JavaScript runs inside an engine - most famously V8 (used in Chrome and Node.js). The engine takes your raw .js text, parses it into an Abstract Syntax Tree (AST), compiles it to bytecode, and executes it. All of this happens in milliseconds, silently, every time your script runs.

During execution, the engine relies on two key data structures:

  • The call stack - tracks where in your program execution currently is
  • The heap - stores objects and data in memory

Everything interesting about runtime behavior flows from how these two interact. Let's start with the stack.

The Call Stack: JavaScript's Execution Tracker

The call stack is a LIFO (Last In, First Out) data structure. Think of it as a stack of plates, you can only add or remove from the top.

Every time a function is invoked, JavaScript pushes a new frame onto the stack. When the function returns, that frame is popped off. The stack tells the engine: "This is what I'm currently doing, and this is what I need to return to when I'm done."

A Simple Example

function greet(name) {
  return `Hello, ${name}!`;
}

function main() {
  const message = greet("Alice");
  console.log(message);
}

main();
Enter fullscreen mode Exit fullscreen mode

Here's the call stack lifecycle for this code:

  1. main() is called → pushed onto stack
  2. Inside main, greet("Alice") is called → pushed on top of main
  3. greet returns "Hello, Alice!" → popped off stack
  4. Back in main, console.log(message) is called → pushed, then popped
  5. main returns → popped off
  6. Stack is empty

This is exactly how the JavaScript runtime tracks your program's position at every moment.

Stack Overflow: When the Stack Gets Too Full

You've probably seen the Maximum call stack size exceeded error. This happens with infinite recursion a function that calls itself without a base case:

function infinite() {
  return infinite(); // Never stops!
}

infinite(); // RangeError: Maximum call stack size exceeded
Enter fullscreen mode Exit fullscreen mode

Each recursive call pushes a new frame. Since there's no stopping condition, the stack fills up and crashes. This is a real-world concern in complex recursive algorithms, and understanding the stack helps you design better base cases and termination conditions.

Execution Context: The Environment Where Code Runs

If the call stack is where your code runs, the execution context is how it runs, the environment that wraps each piece of code with all the information it needs.

Every execution context has three components:

  • Variable Environment stores var declarations and function declarations
  • Lexical Environment stores let, const, and the outer scope reference
  • this binding determines what this refers to inside that context

Types of Execution Contexts

1. Global Execution Context (GEC)

This is created when your script first loads. There's only one. It sets up the global object (window in browsers, global in Node.js) and the initial this binding.

2. Function Execution Context (FEC)

Created every time a function is invoked. Each function call gets its own brand-new execution context, completely isolated from others.

3. Eval Execution Context

Created inside eval() rarely used and generally discouraged. Skip it.

The Two Phases of an Execution Context

This is where things get genuinely interesting. Every execution context goes through two phases before any code actually runs:

Phase 1: Creation Phase

During this phase, the JavaScript engine:

  • Creates the variable object
  • Sets up the scope chain
  • Determines the value of this
  • Hoists declarations (more on this shortly)

Phase 2: Execution Phase

Now the code actually runs line by line, assignments are made, and functions are called.

Understanding this two-phase process is the key to understanding hoisting, one of JavaScript's most misunderstood behaviors.

Hoisting: What It Really Is (Not What You Think)

Hoisting is often explained as "JavaScript moves declarations to the top of the file." That's a useful mental model, but it's technically inaccurate and leads to bad intuitions.

What actually happens: during the creation phase of an execution context, the engine scans for all variable and function declarations and registers them in memory before executing a single line of code.

The code doesn't physically move. The declarations are simply known before execution begins.

Function Hoisting

sayHello(); // Works! Outputs: "Hello!"

function sayHello() {
  console.log("Hello!");
}
Enter fullscreen mode Exit fullscreen mode

Function declarations are fully hoisted, the entire function body is available from the start of its scope. This is why calling sayHello() before its declaration works perfectly.

var Hoisting

console.log(name); // undefined (not an error!)
var name = "Alice";
console.log(name); // "Alice"
Enter fullscreen mode Exit fullscreen mode

var declarations are hoisted, but their assignments are not. During the creation phase, name is registered in memory and initialized to undefined. The assignment = "Alice" only happens when that line is reached during execution.

This is the classic hoisting trap. Many developers expect an error on line 1 and are confused by undefined instead.

let and const Hoisting, The Temporal Dead Zone

console.log(age); // ReferenceError: Cannot access 'age' before initialization
let age = 25;
Enter fullscreen mode Exit fullscreen mode

let and const are also hoisted they're registered during the creation phase but unlike var, they are not initialized. They exist in a state called the Temporal Dead Zone (TDZ) from the start of their scope until the declaration is reached.

Accessing them in the TDZ throws a ReferenceError. This is actually safer behavior than var's silent undefined.

Function Expressions Are NOT Hoisted the Same Way

greet(); // TypeError: greet is not a function

var greet = function() {
  console.log("Hi!");
};
Enter fullscreen mode Exit fullscreen mode

Here, greet is declared with var, so it's hoisted as undefined. Calling undefined() throws a TypeError. This is a fundamental difference between function declarations and function expressions.

How Scope Chain and Closures Tie Into All This

Each execution context holds a reference to its outer environment the context it was created inside. This chain of references is called the scope chain.

When you reference a variable, JavaScript looks in the current context first. If it doesn't find it, it walks up the scope chain until it reaches the global context (or throws a ReferenceError).

const prefix = "Hello";

function outer() {
  const name = "World";

  function inner() {
    console.log(`${prefix}, ${name}!`); // Accesses both outer scopes
  }

  inner();
}

outer(); // "Hello, World!"
Enter fullscreen mode Exit fullscreen mode

inner has access to name (from outer) and prefix (from global) because of the scope chain built during the creation phases of those execution contexts. This is exactly how closures work — and why they're so powerful in JavaScript frameworks like Angular and Vue.

A Real-World Bug Explained

Here's a classic interview question that trips up even experienced developers:

for (var i = 0; i < 3; i++) {
  setTimeout(function() {
    console.log(i);
  }, 1000);
}
// Outputs: 3, 3, 3 (not 0, 1, 2!)
Enter fullscreen mode Exit fullscreen mode

Why? Because var is function-scoped, not block-scoped. All three callbacks share the same i in the same execution context. By the time the timeouts fire, the loop has completed and i is 3.

The fix is simple — use let:

for (let i = 0; i < 3; i++) {
  setTimeout(function() {
    console.log(i);
  }, 1000);
}
// Outputs: 0, 1, 2 ✓
Enter fullscreen mode Exit fullscreen mode

let creates a new binding for each iteration, so each callback captures its own i. This works because let creates a new lexical environment per block — exactly what we learned about execution contexts.

This kind of subtle bug is common in production codebases. Teams building custom software solutions need developers who can reason about execution context, not just syntax.

Putting It All Together

Let's trace through a complete example using everything we've covered:

var x = 10;

function add(a, b) {
  var result = a + b;
  return result;
}

function calculate() {
  var y = 20;
  return add(x, y);
}

console.log(calculate()); // 30
Enter fullscreen mode Exit fullscreen mode

Step-by-step execution:

  1. Global EC createdx hoisted as undefined, add and calculate fully hoisted
  2. Execution phasex assigned 10
  3. calculate() called → new FEC pushed onto stack
  4. In calculate EC: y hoisted, then assigned 20
  5. add(x, y) called → new FEC pushed with a=10, b=20
  6. In add EC: result hoisted, assigned 30, returned
  7. add EC popped off stack
  8. Back in calculate: 30 returned
  9. calculate EC popped off stack
  10. console.log(30) runs in global EC

That's the full picture. Every step is predictable once you understand the mechanics.

Why This Knowledge Matters for Real Development

Understanding call stack and execution context isn't academic - it has direct practical impact:

  • Debugging becomes faster because you can reason about what's in scope and when
  • Performance improves when you understand how closures hold references in memory
  • Interviews become more manageable when you can explain hoisting with confidence
  • Framework code (React hooks, Vue reactivity, etc.) makes more sense at a fundamental level

If your team works with technologies like NestJS or Python alongside JavaScript, a solid foundation in how each language handles execution makes you a significantly more versatile engineer. Developers who understand internals write better APIs, build more reliable systems, and debug faster, all essential whether you're working on a startup or enterprise web app development project.

Conclusion

JavaScript's call stack, execution context, and hoisting aren't obscure trivia, they're the foundation everything else is built on. Once you understand that code runs in two phases (creation then execution), that the stack tracks where you are, and that hoisting is just the engine being aware of declarations early, a whole category of confusing JavaScript behavior snaps into clarity.

The next time you see an unexpected undefined, a ReferenceError that seems wrong, or a closure that captures the wrong value, you'll know exactly where to look.

For developers building production-grade JavaScript applications, this knowledge pairs naturally with deeper topics like CI/CD pipelines, software testing best practices, and frontend development fundamentals. The engine doesn't judge, but understanding it will make you a sharper, more confident developer.

Top comments (0)