Chapter 8: Execution Context — What Actually Happens When JS Run


Chapter 8: Execution Context — What Actually Happens When JS Runs Your Code

Ananya, a fresher in Chennai, is two weeks into her first job as a junior front-end developer. Like most beginners, she pictures the JavaScript engine as a reader that starts at line 1 and works its way down, one line at a time. For most code that picture gives the right answer. Then, during a code review, a senior teammate shows her two lines where console.log(a) comes before var a = 10 and asks her to guess the output. Ananya says it will fail, because a does not exist yet. The console prints undefined. So how did the engine know about a before it reached that line?

console.log(a); // undefined
var a = 10;

If JavaScript only read line by line, line 1 should fail, because a has not been created yet. Instead it prints undefined, which means the engine already knew that a exists. The reason is that JavaScript does some preparation work before it runs your code, and the place where that preparation happens is called an execution context.

In Chapter 7 we looked at objects and arrays, the data your program works with. This chapter is about what the engine does with your code itself. It is the foundation for the next few chapters: the call stack, hoisting, scope and closures all make much more sense once you know what an execution context holds and when it is created.

What Is an Execution Context?

An execution context is the environment in which a piece of JavaScript code runs. It is the engine's record of everything it needs while that code is running: which variables and functions exist, what values they hold right now, where to look for names that are not found locally, and what the keyword this means.

A restaurant is a useful picture. Before the first customer walks in, the staff arrange the tables, prepare the ingredients, open the cash counter and switch on the kitchen. Only then does service start. JavaScript works the same way. It first sets up a workspace for your code, and only after that does it start running the statements. Even a one-line program such as console.log("Hello World"); gets this workspace, because no JavaScript code can run outside an execution context.

Each execution context holds three main things. Keep these in mind, as the rest of the chapter fills them in one by one:

PartPlain meaningExample
Memory (variables and functions)A list of every name declared in this piece of code and its current valueprice → 200, total → function
Outer referenceA link to the place where this code was written, so the engine knows where to look for names it cannot find locallyA function written at the top level links to the global context
thisThe value of the keyword this while this code runswindow in a browser script

Types of Execution Context

JavaScript has three kinds of execution context. You will work with the first two every day.

  • Global execution context. Created once, when your script or file starts. All top-level code (code that is not inside any function) runs here. There is only one per script, and it stays around until the program ends or the page is closed.
  • Function execution context. Created every time a function is called, not when it is defined. Call the same function five times and you get five separate contexts, one after another.
  • Eval execution context. Created for code passed to eval(), which turns a string into running code. Modern code avoids eval() because it is slow, hard to debug and a security risk, so we will not spend time on it.

In the newer standard, JavaScript modules (files loaded with import) also get their own top-level context. We will come back to that when we look at this.

The Two Phases: Creation and Execution

Every execution context, global or function, is built in two phases. This is the single most useful idea in the chapter, and most "strange" JavaScript behaviour is explained by it.

  1. Creation phase (also called the memory phase). The engine scans the code for declarations and sets aside memory for every variable and function it finds. No line of your code runs yet.
  2. Execution phase. The engine now runs the code from top to bottom. Assignments fill in real values, functions get called, and output gets printed.

The important detail is what gets stored in the creation phase. It depends on how the name was declared, and the three kinds behave very differently:

DeclarationStored in the creation phase asUsing it before its line
var x = 10;x → undefinedGives undefined, no error
let x = 10; / const x = 10;x is registered but uninitialisedReferenceError: Cannot access 'x' before initialization
function greet() { ... }greet → the complete functionWorks; you can call it before its line
var greet = function () { ... };greet → undefined (it is just a var)TypeError: greet is not a function

The gap between the start of the context and the line where a let or const is declared has a name: the temporal dead zone, or TDZ. The variable exists in memory during that gap, but JavaScript refuses to let you read or write it. This is deliberate. Reading a value that is not ready yet is almost always a bug, and the TDZ turns that bug into a clear error instead of a quiet undefined.

Ananya tries this the next morning. She changes var a = 10 to let a = 10 in the same two-line snippet, and the quiet undefined becomes ReferenceError: Cannot access 'a' before initialization. The variable still exists from the start of the context, but now the engine refuses to hand out a value that is not ready. For her, that error is good news: a bug that stops the program on the exact line is far easier to find than one that quietly prints undefined.

Here is all of it in one runnable file. Each line's output is shown as a comment:

console.log(a);           // undefined
var a = 10;
console.log(a);           // 10

try { console.log(b); } catch (e) { console.log(e.message); }
// Cannot access 'b' before initialization
let b = 1;

console.log(sayHello());  // Hello
function sayHello() { return "Hello"; }

try { sayHi(); } catch (e) { console.log(e.message); }
// sayHi is not a function
var sayHi = function () { return "Hi"; };

try { sayBye(); } catch (e) { console.log(e.message); }
// Cannot access 'sayBye' before initialization
const sayBye = () => "Bye";

Notice the difference between the last two. sayHi was declared with var, so in the creation phase it got undefined, and calling undefined() gives a TypeError. sayBye was declared with const, so it was still in the TDZ, and you get a ReferenceError instead. Only a real function declaration is stored whole and can be called early. This "available before its line" behaviour has its own name, hoisting, and Chapter 10 covers it in detail.

Does the engine really make two passes? Not literally. Modern engines like V8 parse and compile your code in clever ways, and they do not walk through it twice. The "two phases" are a model of what the language specification promises: all declarations of a scope are set up before any of its statements run. The model predicts the real behaviour exactly, which is why it is worth learning.

A Gotcha: Function and var With the Same Name

Because function declarations are stored whole in the creation phase and var assignments only happen in the execution phase, a name used for both can change type halfway through the program:

console.log(typeof greet); // function
var greet = "hi";
function greet() {}
console.log(typeof greet); // string

In the creation phase, the function wins: var greet does not overwrite a function that is already stored. In the execution phase, the line greet = "hi" runs and replaces it with a string. With let or const this code would not even load, because they refuse a duplicate name in the same scope. That is one more reason to prefer them.

The Global Execution Context

As soon as your file starts, the engine creates the global execution context and runs its creation phase over all top-level code. Think of it as the head office of your program: every function call later starts from here and returns here.

In the creation phase, the global context also sets up two things you did not write yourself. The first is the global object, which holds built-in features. In a browser it is called window, in Node.js it is called global, and both can be reached with the standard name globalThis. The second is the value of this for top-level code, which surprisingly depends on where the code runs.

this in the Global Context

Where the top-level code runsValue of this
Browser, classic <script>window (the global object)
Browser, <script type="module">undefined
Node.js, CommonJS file (.js with require, or .cjs)module.exports, which starts as an empty object {}
Node.js, ES module (.mjs, or "type": "module" in package.json)undefined

The Node.js rows are easy to check yourself. Save this as a .js file and run it with node:

// this-check.js (CommonJS)
console.log(this);                     // {}
console.log(this === module.exports);  // true

module.exports.answer = 42;
console.log(this);                     // { answer: 42 }

Node wraps each CommonJS file in a hidden function and calls it with this set to module.exports, so the empty object you see is really the file's export object. Rename the same file to this-check.mjs and the first line prints undefined, because ES modules always run in strict mode with no global this. If you are not sure which module system your project uses, the CommonJS vs ES Modules post explains the difference.

Global var vs let in the Browser

In a classic browser script, top-level var variables and function declarations become properties of window. Top-level let and const do not; they live in a separate area of the global context.

// In a classic browser <script>
var a1 = 1;
let b1 = 2;
function f1() {}

console.log(window.a1);         // 1
console.log(window.b1);         // undefined
console.log(typeof window.f1);  // function

This is one reason global var is risky in the browser: your variable can accidentally overwrite a built-in such as window.name or a variable from another script. In a Node.js file, and in any module, top-level var is local to that file and does not touch globalThis at all.

The Function Execution Context

Every function call gets a brand-new execution context, and it goes through the same two phases. Think of it as a temporary room the head office books for one job. The room is set up, the job is done, and the room is handed back.

The creation phase of a function context does a little more work than the global one:

  • Parameters are stored with the values passed in the call. Missing arguments get undefined.
  • For regular functions (not arrow functions), an arguments object is created that holds every value passed, even extra ones.
  • Local var, let, const and function declarations are registered by the same rules as in the global context.
  • this is decided by how the function was called. Arrow functions are the exception: they do not get their own this and use the one from the code around them.
  • The outer reference is set to the place where the function was written.
function sum(a, b) {
  console.log(arguments.length, a, b);
}

sum(1, 2, 3);  // 3 1 2
sum(1);        // 1 1 undefined

When the function returns, its context is removed and control goes back to the context that called it. Because each call starts from scratch, local variables do not remember anything between calls:

function visit() {
  var count = 0;
  count++;
  return count;
}

console.log(visit()); // 1
console.log(visit()); // 1  (a fresh count every call)

The engine keeps track of which context is running, and which one to return to, with a structure called the call stack. The global context sits at the bottom, each call is pushed on top, and each return pops it off. Chapter 9 is all about it.

Local Variables Stay Local

function test() {
  let secret = "Hidden";
  console.log(secret);
}

test();               // Hidden
console.log(secret);  // ReferenceError: secret is not defined

It is often said that this error happens "because the function's context has been destroyed". That is not the full reason. Code in the global context could never see secret, even while test() was still running, because lookup only goes outwards from where the code is written, never inwards into a function. The next section shows how that lookup works.

Variable Environment, Lexical Environment and the Scope Chain

So far we have called the memory part of a context its "memory". The specification has more precise names, and you will meet them in interviews and in other articles, so it helps to know what they mean in simple words.

  • Environment record: the actual table of names and values, like price → 200.
  • Lexical environment: an environment record plus a link to the outer environment. "Lexical" just means "based on where the code is written in the file".
  • Variable environment: the lexical environment that holds the context's var declarations. When a function starts, both names point to the same place. They only separate when a block such as { }, if or for creates a small new lexical environment for its let and const, while var stays in the function's variable environment.

That last point explains a classic difference between var and let. A block gives let its own small environment, but it does not create a new execution context, so var inside a block belongs to the surrounding function or global code:

var x = 1;
let y = 1;

{
  var x = 2;  // same x as above
  let y = 2;  // a new y that lives only in this block
}

console.log(x, y); // 2 1

When code uses a name, the engine first looks in the current environment. If the name is not there, it follows the outer reference to the next environment, then the next, until it reaches the global one. If the name is still not found, you get a ReferenceError. This chain of links is the scope chain.

const city = "Delhi";

function outer() {
  const shop = "Kirana";
  function inner() {
    const item = "rice";
    console.log(item, shop, city);
  }
  inner();
}

outer(); // rice Kirana Delhi

Inside inner, item is found locally, shop is found one step out in outer, and city is found two steps out in the global environment. The key word is written. The outer reference is fixed by where the function sits in the file, not by who calls it:

const city = "Delhi";

function showCity() {
  console.log(city);
}

function trip() {
  const city = "Mumbai";
  showCity();
}

trip(); // Delhi

showCity is called from inside trip, but it was written at the top level, so its outer reference points to the global environment and it prints "Delhi". It never looks inside trip. This rule is called lexical scoping, and it is exactly what makes closures possible in Chapter 12.

Step-by-Step Trace of a Small Program

Let's follow a short bill calculator through both phases of both contexts. It works out the total for 3 items at ₹200 each, plus 18% tax:

var price = 200;
let qty = 3;

function total(p, q) {
  var tax = p * q * 0.18;
  return p * q + tax;
}

var bill = total(price, qty);
console.log(bill); // 708

The diagram below shows the two contexts at the moment total is running. The global context waits underneath while the function context does its work, and the function's outer reference points back to the global memory.

Global execution context Created once, when the file starts Memory price → 200 qty → 3 total → function bill → undefined (bill is filled after the call) this → window / {} / undefined Outer reference → none Running: var bill = total(price, qty) Function context: total(200, 3) Created when total is called Memory p → 200 q → 3 tax → undefined → 108 Outer reference → global Returns 708, then is removed Arrow: names not found in total are looked up in global memory

Here is every step, in order. The "Memory after this step" column shows only what changed:

#Context and phaseWhat happensMemory after this step
1Global, creationEngine scans top-level code; sets this and the global objectprice → undefined, qty → uninitialised (TDZ), total → function, bill → undefined
2Global, executionvar price = 200;price → 200
3Global, executionlet qty = 3; (TDZ ends here)qty → 3
4Global, executionThe function declaration is skipped; it was stored in step 1No change
5Global, executiontotal(price, qty) is called; global pausesNo change yet; bill is still undefined
6total, creationNew function context; parameters, arguments, this and outer reference set upp → 200, q → 3, tax → undefined
7total, executionvar tax = p * q * 0.18;tax → 108
8total, executionreturn p * q + tax; gives 600 + 108Returns 708; the function context is removed
9Global, executionBack in global; the returned value is assignedbill → 708
10Global, executionconsole.log(bill); prints 708No change; the program ends

Two things are worth noticing. First, total never had its own price or qty; it only knew p and q, the copies passed in as arguments. Second, at step 5 the global context is not finished, only paused. It resumes at exactly the same spot when the function returns, which is the job of the call stack.

Calling two functions one after the other follows the same pattern twice. With one(); two();, the global context creates a context for one, removes it when one returns, then creates a fresh one for two:

Global context (running)
  │
  ├── one() called  → context for one created → prints "One" → removed
  │
  ├── two() called  → context for two created → prints "Two" → removed
  │
Global context continues until the end of the file

"Removed" Does Not Always Mean "Forgotten"

Most tutorials say a function context is "destroyed" when the function returns. That is true for the context itself: it is popped off the call stack and will never run again. Its variables, though, are only freed when nothing else needs them. If an inner function that uses those variables is still around (because it was returned, or saved as an event handler), the variables are kept alive for it. That is a closure, and it is why a function can "remember" values after its parent has finished. You only need to know that it exists for now; Chapter 12 explains it properly.

Where This Shows Up in Real Apps

Every user action in an app usually triggers at least one function call, and therefore at least one new execution context. When you tap "Pay" in a UPI app, a function like transferMoney() runs in its own context with its own amount, receiver and status variables. A food delivery app might call calculateDeliveryCharge(distance) for each restaurant in the list, and each of those calls gets a separate context, so the distance of one restaurant never leaks into another. The same is true for loginUser() and placeOrder() on a shopping site.

This isolation is what lets thousands of calls to the same function run safely in one program. Problems start only when code reaches outside its context, for example by changing a global variable. Then two calls that should be independent start affecting each other, which is one of the hardest kinds of bug to trace.

Common Mistakes (and the Fix)

MistakeWhat happensFix
Using a var before its line and expecting an errorYou silently get undefined and the bug shows up laterUse let/const, which throw a clear ReferenceError in the TDZ
Calling a function expression before its lineTypeError: ... is not a function (with var) or ReferenceError (with let/const)Define it above the call, or use a function declaration if early calls are needed
Assuming typeof is always safetypeof x throws if x is a let/const still in its TDZDeclare variables at the top of their scope
Expecting a function to see the caller's variablesIt sees variables from where it was written, not from where it was calledPass the values you need as arguments
Relying on top-level thisIt is window, {} or undefined depending on the environmentUse globalThis for the global object and module.exports/export for exports
Using a var and a function with the same nameThe name changes type halfway through the programGive every declaration a unique name; let/const enforce this

The typeof case surprises people, because typeof on a name that was never declared is completely safe and returns "undefined". Only a name that is declared later with let or const throws:

console.log(typeof neverDeclared); // undefined

try { typeof later; } catch (e) { console.log(e.message); }
// Cannot access 'later' before initialization
const later = 5;

My Opinion

Learn the two-phase model, then mostly forget about it while writing code. It is the best tool for explaining confusing output and for interviews. In everyday code, the goal is to write things in an order where the creation phase never surprises anyone.

Use const by default and let when you must reassign. The TDZ is a feature: it turns "used before it was ready" into an error you see immediately, instead of a quiet undefined that breaks something three screens later. There is very little reason to write var in new code.

Declare things before you use them, even when JavaScript would allow otherwise. Calling a function declaration above its definition works, but code that reads top to bottom is easier for the next person. Many teams turn on the ESLint no-use-before-define rule for this reason, and it is a sensible default.

Keep the global context small. Every top-level variable is visible to everything in the file, and in classic browser scripts var even lands on window. Put logic inside functions or modules so each piece of work gets its own isolated context.

Use the debugger to see contexts for real. Put a breakpoint inside a function in Chrome DevTools or VS Code and look at the "Scope" and "Call Stack" panels. You will see the local, closure and global environments listed exactly as described here, which makes the idea stick far better than any diagram.

Try It Yourself

Predict the output of each snippet before running it with Node.js or in your browser console (press F12 → Console). Answers are below.

1. console.log(n);
   var n = 5;

2. console.log(m);
   let m = 5;

3. console.log(double(4));
   function double(x) { return x * 2; }

4. console.log(triple(4));
   var triple = function (x) { return x * 3; };

5. var color = "red";
   function show() { console.log(color); }
   function paint() { var color = "blue"; show(); }
   paint();

6. var k = 1;
   { var k = 2; }
   console.log(k);

7. function add(a, b) { return arguments.length; }
   console.log(add(1, 2, 3, 4));

8. // saved as test.js and run with: node test.js
   console.log(this);
Show answers
1. undefined   → var is stored as undefined in the creation phase
2. ReferenceError: Cannot access 'm' before initialization → m is in the TDZ
3. 8           → function declarations are stored whole, so the early call works
4. TypeError: triple is not a function → triple is a var holding undefined
5. red         → show was written at the top level, so it looks in global
6. 2           → a block does not create a new context; var k is the same variable
7. 4           → arguments holds every value passed, not just the parameters
8. {}          → in a CommonJS file, top-level this is module.exports

Frequently Asked Questions

Is execution context the same as scope?

No, but they are closely linked. Scope is the set of names your code can see, and it is fixed by where the code is written. An execution context is created at run time when code actually runs, and it carries the lexical environment that makes that scope work, plus this.

How many execution contexts can exist at once?

One global context, plus one for every function call that has started but not yet returned. Nested calls stack up, so if a() calls b() which calls c(), four contexts exist together. Too many (for example from runaway recursion) gives the "Maximum call stack size exceeded" error covered in Chapter 9.

Do arrow functions get an execution context?

Yes. Every call to an arrow function creates a function context with its parameters and local variables. The difference is that arrow functions do not get their own this or arguments; they use the ones from the surrounding code.

Does an if block or a for loop create an execution context?

No. Blocks create a new lexical environment for let and const, which is why those variables stay inside the block. They do not create a new execution context, so var inside a block belongs to the surrounding function or to the global code.

Key Takeaways

  • An execution context is the environment in which code runs: its variables and functions, an outer reference, and this.
  • There is one global context per script, and a new function context for every function call.
  • Each context has a creation phase (declarations set up) followed by an execution phase (code runs line by line).
  • In the creation phase, var becomes undefined, let/const stay uninitialised in the TDZ, and function declarations are stored whole.
  • The outer reference is fixed by where a function is written; following these links is the scope chain.
  • Top-level this is window in a classic browser script, {} (module.exports) in a CommonJS file, and undefined in ES modules.
  • Blocks create a lexical environment for let/const but not a new execution context.
  • A finished function's context is removed, but its variables survive if a closure still uses them.

Next, Chapter 9 shows how the engine stacks these contexts on top of each other with the call stack.

References

Related Articles

Post a Comment

0 Comments