Skip to the chapter
←JavaScriptGGroundworkJSJavaScript
JavaScript/Beginner/B2
2 / 41 written chapters
/

The cover
  1. B1Setup & mental model
  2. B2Execution context
  3. B3var, let & const
  4. B4Scope
  5. B5Single thread
  6. B6In the browser
  7. B7Types & values
  8. B8Strings & Math
  9. B9Operators & flow
  10. B10Functions
  11. B11Objects & arrays
  12. B12this
  13. B13Closures
  14. B14DOM & events
  15. B15Callbacks & promises
  16. B16Errors & tools
  17. B17Guided project
  18. ★Cheat page
  1. I1Scope & functions, properly
  2. I2Objects deeply
  3. I3Prototypes & OOP
  4. I4Async, properly
  5. I5Modules & tooling
  6. I6Regex & dates
  7. I7Error handling
  8. I8Real-time connections
  9. I9Offline & storage
  10. I10Browser APIs
  11. I11Modern JS
  12. ★Cheat page
  1. A1Engine & memory
  2. A2Advanced async
  3. A3Metaprogramming
  4. A4Types & data
  5. A5Patterns & architecture
  6. A6Performance
  7. A7Security
  8. A8Ecosystem
  9. A9Testing
  10. A10Observability
  11. A11Interview bank
  12. A12Build it yourself
  13. ★Cheat page

JSB2 · Part 1 · Beginner

Execution context

What JavaScript does with your code before it runs a single line of it.

11 min read1 diagram8 sections2 exercises
2 of 41

In this chapter

  • Your code is read twice
  • What a context actually holds
  • Hoisting is not code moving upwards
  • The Temporal Dead Zone, precisely
  • var, let and const are next

and 3 more

about 11 min
08On this page
  1. Your code is read twice
  2. What a context actually holds
  3. Hoisting is not code moving upwards
  4. The Temporal Dead Zone, precisely
  5. var, let and const are next
  6. A fresh context per call
  7. Looking a name up: the scope chain
  8. The traps, collected

Your code is read twice

The sentence that unlocks most of JavaScript's "weird" behaviour is this: JS does not start at line 1. Before a single statement runs, it walks the whole scope once and builds memory for it. Only then does it go back to the top and start executing.

Two phases, always in this order:

  • Creation phase — scan the code, find every declaration, put each name into memory, and give it a starting state. Nothing is running yet.
  • Execution phase — now run the statements top to bottom. Assignments happen here, and only here.

Every confusing beginner result — a variable that is undefined instead of erroring, a function you can call above where you wrote it, a let that throws on a line that looks harmless — is a fact about phase one.

The two phases, one step at a time
Phase
Memory — this scope's names
Console

What a context actually holds

The box JS builds to run a chunk of code in has a name: an execution context. You never touch one directly, but three things live inside it and all three are things you already argue with.

EXECUTION CONTEXT 1 · Variable environment every name declared in this scope, and what it currently holds a = 1 · b = 2 · f = fn 2 · Outer reference → the scope chain 3 · this Where it was WRITTEN fixed before any call How it was CALLED arrows borrow it instead One of these per program (global), plus a fresh one per function CALL.
A context is memory + a link to the memory around it + a this. The link comes from where the code sits; the this comes from the call.

Those two arrows point in opposite directions, and mixing them up is the classic mistake. The outer reference is lexical: read the source and you know it. this is not — for a normal function it is filled in fresh on every call, from the shape of that call: user.greet() gives user, a bare greet() gives nothing useful. Only an arrow function skips that step and borrows the this of the scope it was written in. The rules are in the this chapter; the one-line version is "the call site decides".

The global context gets a this too, and it depends on how the file was loaded: window at the top of a classic <script>, undefined at the top of an ES module, and module.exports at the top of a CommonJS file in Node. Same source, three answers — which is why top-level code should never lean on it. Use globalThis when you genuinely mean the global object.

You meet two kinds immediately. The global execution context is created once when the file loads — that is the one the demo above was walking. A function execution context is created fresh on every call, not once per function. Call a function three times and you get three contexts, each with its own memory, created independently.

Say it like this → "A context is not the function. It is one run of the function."

Hoisting is not code moving upwards

The usual explanation — "declarations get moved to the top of the file" — is a lie that happens to predict the right answer about half the time. Nothing moves. The creation phase simply got there first. The difference matters the moment let is involved, because the moving story predicts undefined and the real mechanism predicts a crash.

Here is what the creation phase does, per kind of declaration:

You wrote Name exists before the line runs? Holding what? Touching it early gives
var x = 1 yes undefined undefined — silent, no error
let x = 1 yes nothing — uninitialised ReferenceError
const x = 1 yes nothing — uninitialised ReferenceError
function f() {} yes the entire function it just works
var f = function () {} yes undefined TypeError: f is not a function
class C {} yes nothing — uninitialised ReferenceError
x = 1 (no keyword) no — creates a global at runtime, or throws in strict mode

Read the last two rows together and you have the single most common beginner bug in this area: ReferenceError means the name does not exist yet; TypeError: not a function means the name exists fine, it just happens to hold undefined right now. Two different errors, two different fixes.

f();                // works — declaration, stored during creation
g();                // what error do you expect here?

function f() { console.log("f ran"); }
var g = function () { console.log("g ran"); };

Run it. f() is fine. g() throws TypeError, not ReferenceError — because g exists, holding undefined, and undefined() is not a callable thing.

When a var and a function share a name

The table implies a tie-break, and interviewers love asking for it. If one scope has both var foo and function foo() {}, creation phase handles the function first and stores the whole body. The var then finds the name already taken and does nothing — it never resets it to undefined. Order in the file does not matter.

console.log(typeof foo);  // "function" — the declaration won creation
var foo;
function foo() {}
console.log(typeof foo);  // "function" — a bare var foo assigns nothing
var foo = 1;
console.log(typeof foo);  // "number" — but an assignment still runs in phase two

The function wins the creation phase; any = still wins the execution phase. (With let foo in place of var foo there is no contest — it is a SyntaxError.)

Dry run: creation phase first, then three lines of execution

StepLinefoo holdstypeof foo
1creation phasethe function foo(){} — declarations are stored whole, and var foo finds the name already taken and does nothing—
2console.log(typeof foo)still the function"function"
3var foo;unchanged — a bare var with no = assigns nothing—
4function foo() {}unchanged — already stored in creation—
5console.log(typeof foo)still the function"function"
6var foo = 1;1 — this is an assignment, and assignments run in the execution phase—
7console.log(typeof foo)1"number"

Only line 6 changes anything. Everything before it is either the creation-phase tie-break (function beats var) or a no-op var declaration with nothing to assign — proof that "declared twice" and "assigned twice" are different questions.

The Temporal Dead Zone, precisely

The TDZ is not "before the declaration line" in the file. It is the span between two moments: the name being created (when the scope is entered) and the name being initialised (when its declaration statement actually runs). Any read in between throws.

{
  // ---- b's TDZ starts here, at the { ----
  console.log(a);   // undefined — var was pre-filled
  console.log(b);   // ReferenceError: Cannot access 'b' before initialization

  var a = 1;
  let b = 2;        // ---- b's TDZ ends on THIS line ----

  console.log(b);   // 2 — perfectly fine from here on
}
⚠ typeof does not save you typeof someNameThatWasNeverDeclared is famously safe — it returns the string "undefined" instead of throwing. That exception does not apply inside a TDZ: typeof b before let b throws a real ReferenceError. A declared-but-uninitialised name is a stricter thing than a name that was never mentioned at all.

It exists on purpose. Without it, const would be observable in an unassigned state, which makes "a const always has its value" untrue. And the error it produces points at the real mistake — using something before you set it up — instead of quietly handing you undefined and letting the damage surface four functions later.

var, let and const are next

The table above already holds the part of var, let and const that belongs to this chapter: what each one holds before its line runs. Everything else about them — block scope, why a var loop and a let loop print different numbers, what const actually locks, redeclaration, and what happens at the top of a file — has a chapter of its own, straight after this one.

A fresh context per call

Everything above described one scope. Call a function and the same two-phase process happens again, in a brand-new context, with its own memory:

function counter() {
  var n = 0;        // created fresh on every single call
  n++;
  return n;
}
console.log(counter(), counter(), counter());  // 1 1 1 — not 1 2 3

Three calls, three contexts, three separate n boxes, each born at the start of the call. The question of where these contexts are stacked — and what happens when one of them never finishes — is One thread, one stack.

A function's creation phase has one job the global one does not: before any declaration inside the body is looked at, the parameters are created and filled with the values the caller passed. A parameter nobody passed gets undefined. Normal (non-arrow) functions also get an arguments object at this point, holding every value that was passed, however many parameters you wrote.

function f(a, b) {
  console.log(a, b, arguments.length);  // 1 undefined 1 — set up before line 1 of the body
  var a;                                // finds a already there, changes nothing
  console.log(a);                       // still 1
}
f(1);

Same tie-break as before: a var with the same name as a parameter is ignored. A function a() {} inside the body would replace it, though — function declarations are stored after the parameters, and they overwrite.

⚠ "Thrown away" has an exception The call ends and its context leaves the stack — but the memory it built is only freed if nothing still points at it. Return an inner function that uses n, and that function keeps the whole environment alive for as long as it exists. That is a closure, and it is exactly how makeCounter in the closures chapter counts up instead of printing 1 1 1. What that costs in memory is covered in Engine & memory.

Looking a name up: the scope chain

When a line uses a name that is not in the current context's memory, JS follows the outer reference one level out and looks again, then again, until it either finds the name or runs out of scopes and throws ReferenceError. It never searches inwards, and it never searches sideways.

const level = "global";

function outer() {
  const tool = "hammer";

  function inner() {
    console.log(tool);   // not here -> found one level out
    console.log(level);  // not here, not there -> found in global
    console.log(nope);   // nowhere -> ReferenceError
  }

  inner();
}
outer();

The chain is built from where the function was written, not from who called it. That word is lexical, and it is the reason you can read a function's source and know what it can see without knowing anything about the call site. This is only a first look: every kind of scope, the lookup step by step, shadowing, and what happens when you write to a name the chain cannot find are in Scope, two chapters on.

The traps, collected

  • typeof as a safety check on a let — it throws in the TDZ, so it is only safe for names that were never declared at all.
  • Calling a function expression above its line — TypeError: not a function, because the name exists and holds undefined.
  • Assigning without a keyword — count = 0 in a non-strict script creates a global from anywhere in the codebase. Modules are strict by default, so it throws instead. Good.
  • A var and a function sharing a name — the function wins the creation phase, so the name holds a function until an = runs, not undefined.
Say it like this → "JS builds memory for a scope before it runs it. var gets pre-filled with undefined, let and const get created but left empty until their line runs — and that empty gap is the TDZ."
Interview answer, one sentence

"Before running, the engine makes a pass that creates a binding for every declaration in a new execution context — var starts as undefined, function declarations are fully usable, and let, const and class stay in the temporal dead zone until their line runs — which is all hoisting means; nothing actually moves."

Before you move on
  • Fill in the creation-phase table from memory for var, let, const, a function declaration, and a function expression assigned to a var.
  • Explain why calling a function expression too early throws TypeError while calling an undeclared let throws ReferenceError — and why those are different bugs with different fixes.
  • Predict the tie-break when a var and a function share a name in one scope, and say what changes once an = runs.
  • Say precisely what the Temporal Dead Zone spans — not "above the line in the file," but the gap between a name being created and being initialised.
  • Explain why counter() called three times returns 1 1 1 instead of 1 2 3, in terms of execution contexts.

02Practice this chapter

Opens in the editor: write it, run it, and check it against real tests.

  • 8 testsBuild the creation-phase snapshotbeginner
  • 8 testsWalk the scope chainbeginner
All 35 for this level →
B2

Finished reading?

0 of 41 chapters read in this series

→Next stop

  1. B1Previous · B1Setup & mental model
  2. B2you are here
  3. Next · B3var, let and constThree keywords, four questions they answer differently — where a name lives, when you can touch it, whether it can change, and whether you can declare it twice.14 min read

01On this page

8 sections · 0%

  1. 01Your code is read twice
  2. 02What a context actually holds
  3. 03Hoisting is not code moving upwards
  4. 04The Temporal Dead Zone, precisely
  5. 05var, let and const are next
  6. 06A fresh context per call
  7. 07Looking a name up: the scope chain
  8. 08The traps, collected

[] chapters · t top