Modern JavaScript feels like an interpreted language, but what you observe at runtime (“why is this undefined?”, “why does this throw ReferenceError?”, “why does this closure keep memory?”) is best explained with a small set of semantics:
Execution contexts + call stack (where code runs)
Environment records (where names live)
Binding vs initialization vs assignment (what “hoisting” actually is)
Lexical scope + closure (how name lookup and captured variables work)
thisbinding rules (call-site, strict mode, modules)
This post is a language semantics refresher: spec-aligned mental models + practical interview patterns.
Cheat sheet (2–3 minutes revision)
Execution context: “a frame where code runs.” Global context + one per function call. Each context has LexicalEnvironment and VariableEnvironment(spec model).
Call stack: contexts are pushed on call, popped on return (LIFO).
Lexical scope: names resolve based on where functions are declared, not how they’re called.
Hoisting (useful model) = bindings created during setup before statements execute.
var: binding created + initialized toundefinedduring setup → reading early givesundefined.let/const/class: binding exists but uninitialized until declaration line executes → TDZ →ReferenceError.Function declarations: initialized to the function value during setup → callable before the line.
Closure: function + references to surrounding lexical environment (captured variables).
this: depends on call-site, not lexical scope. In modules, top-levelthisisundefined.
1) Classic script vs ES module (why this matters for semantics)
In the browser, the same .js file can be evaluated in different modes:
Classic script
<script src="app.js"></script>
Historically the “default” script mode.
Top-level
thisis generally the global object (in browsers,window/globalThis), and legacy behaviors are more visible.
ES module
<script type="module" src="app.js"></script>
Enables native
import/exportmodule semantics.Top-level
thisisundefined.Module code is treated more like “modern JS”: strict behaviors are the norm, and top-level declarations don’t automatically become global properties in the same way.
Why you should care: the same snippet involving this, globals, or “what’s in the global scope” can behave differently depending on whether the file is a classic script or a module.
2) JS “interpreted” but still has setup work (compile-ish → execute)
Even without thinking about V8 internals, the JS execution model is commonly explained as:
Setup/instantiation (before statements run) The runtime prepares the execution context and scope bindings.
Execution (run statements top to bottom) Values get computed, assignments happen, functions are called.
MDN explicitly notes the execution model is abstract/theoretical and engines heavily optimize it, but it’s still the best way to reason about semantics.
3) Execution contexts and the call stack
Execution context
When JavaScript runs code, it runs inside an execution context:
A global execution context is created to run the top-level program.
Each time a function is called, a function execution context is created.
The ECMAScript spec models each execution context with (at least) these components:
LexicalEnvironment
VariableEnvironment
Call stack
The runtime keeps an execution context stack:
Calling a function pushes a new context.
Returning from a function pops it.
function a() { b(); }
function b() { c(); }
function c() { /* ... */ }
a();Call stack evolution (conceptually):
push
globalcall
a()→ pushacall
b()→ pushbcall
c()→ pushcreturn
c→ popreturn
b→ popreturn
a→ pop
Why this matters: hoisting/scope/closures are easiest to understand as “what bindings exist in the current execution context’s environments, and what does lookup do as we walk outward?”
4) Scope in modern JS: global, function, block, module
Lexical scope means identifier resolution is based on where code is written (not how it’s called).
Scopes come from syntax constructs:
global scope
function scope
block scope (
{ ... }withlet/const/class)module scope (top-level of an ES module)
The spec models nested scopes using Environment Records linked by [[OuterEnv]] references.
5) Hoisting demystified: binding vs initialization vs assignment
MDN’s glossary definition captures the effect (“appears to move declarations to the top”), but the deeper explanation is more useful for interviews and real debugging.
Key terms
Binding: the scope now has a name (identifier) registered.
Initialization: the binding becomes usable with a value state.
Assignment: execution sets/updates the value.
Hoisting:
During scope setup, bindings are created before statement execution. Different declaration forms initialize differently.
The rules (what actually differs)
Declaration | Scope | Binding created | Initialized when | Reading before declaration |
| function / global (script) | setup | setup →
| ✅ returns
|
| block | setup | when declaration executes | ❌ ReferenceError (TDZ) |
| block | setup | when declaration executes (must) | ❌ ReferenceError (TDZ) |
| function / block* | setup | setup → function object | ✅ callable |
| block | setup | when declaration executes | ❌ ReferenceError (TDZ) |
var: hoisted + initialized to undefined
MDN states var declarations are processed before code runs, which is why variables may appear usable before the declaration line.
console.log(x); // undefined
var x = 10;
console.log(x); // 10This explains classic interview puzzles like:
var myname = "Global";
function showName() {
console.log(myname); // undefined (local var exists, initialized to undefined)
if (0) {
var myname = "Hello";
}
console.log(myname); // undefined
}
showName();let/const/class: hoisted bindings + TDZ (uninitialized until the line)
MDN defines the Temporal Dead Zone: from entering the block until execution reaches the declaration/initialization, accessing the variable throws.
console.log(x); // ReferenceError (TDZ)
let x = 10;const also requires initialization at the declaration:
const a = 1; // OK
// const b; // SyntaxErrorFunction declaration vs function expression
Function declarations are “hoisted with their value” (you can call them before the line).
hello(); // works
function hello() { console.log("hi"); }Function expressions follow the variable’s hoisting rules:
hi(); // TypeError: hi is not a function
var hi = function () {};hiexists and isundefinedduring setup → calling it fails.
6) Lexical scope and closure (and what it means for memory)
Lexical scope: lookup depends on where functions are declared
MDN’s closures guide (and spec-level environment chain model) matches this idea: inner functions can reference bindings from outer scopes.
const x = "global";
function outer() {
const x = "outer";
function inner() {
console.log(x); // "outer"
}
return inner;
}
const fn = outer();
fn(); // "outer"Closure: keeping outer bindings alive
A closure forms when a function retains access to variables from its defining scope, even after that outer function returns.
function makeCounter() {
let count = 0;
return function () {
count++;
return count;
};
}
const c = makeCounter();
c(); // 1
c(); // 2Workplace caution: closures don’t “leak” by default, but they can retain memory
JavaScript is garbage-collected: memory can be reclaimed when objects become unreachable. Closures become a practical issue when something keeps references alive longer than intended (e.g., long-lived event listeners, caches, singletons).
Rule of thumb:
If a closure is intentionally long-lived, keep it small and predictable.
If not, ensure you remove listeners / clear timers / drop references when done.
7) this is not scope: it’s a call-site binding (plus module/strict rules)
MDN’s this docs emphasize that top-level this differs in modules and that this depends on how a function is called.
The big 4 patterns (interview staples)
1) Plain function call
In sloppy mode (non-strict classic script),
thisbecomes the global object.In strict mode,
thisstaysundefined.
"use strict";
function f() { return this; }
console.log(f()); // undefined2) Method call
If you call via an object reference, this is that object (at call time):
const obj = {
name: "A",
show() { console.log(this.name); }
};
obj.show(); // "A"3) call / apply / bind
You can explicitly set this:
function show() { console.log(this.name); }
const o = { name: "X" };
show.call(o); // "X"4) new constructor call
new creates a new object, binds this to it, links prototypes, and returns it (unless the constructor returns a different object).
function Person(name) {
this.name = name;
}
const p = new Person("Mandy");
console.log(p.name); // "Mandy"Arrow functions: this is lexical (captured from outer scope)
Arrow functions don’t have their own this; they use the surrounding this. That’s why they’re commonly used for callbacks inside methods:
const obj = {
name: "A",
later() {
setTimeout(() => {
console.log(this.name); // "A"
}, 0);
}
};
obj.later();Module gotcha: top-level this
In ES modules, top-level this is always undefined. This is a common interview “bonus” question.
8) Practical guidelines (what to do at work)
Prefer clarity over cleverness
Avoid relying on hoisting for readability.
Prefer
constby default,letwhen you reassign, and avoidvarin modern codebases.
Avoid accidental globals / confusing this
Use ES modules (default in most modern stacks) to reduce global-scope hazards.
In callbacks inside methods, use arrow functions (or
.bind) when you need the surroundingthis.
Treat closures as normal—but manage lifetimes
Closures are a feature, not a bug; just be mindful when a closure is held by something long-lived.
9) Interview questions (and the “why” behind them)
Hoisting / TDZ
Q: What’s the output?
console.log(a);
let a = 1;A: ReferenceError due to TDZ (binding exists but not initialized until the declaration executes).
Q: What’s the output?
console.log(a);
var a = 1;A: undefined (binding created + initialized to undefined during setup).
Function declaration vs expression
Q: Why does this work?
foo();
function foo() {}A: Function declarations are initialized with their function value during setup (“hoisted with value”).
Closures + loops (classic)
Q: What prints?
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}A: 3 3 3 because var i is one function/global binding shared by all callbacks.
Follow-up fix: use let for block scoping:
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
// 0 1 2this + strict mode
Q: What’s this in a plain function call under strict mode?
"use strict";
function f(){ return this; }
f();A: undefined.
Module trivia (modern)
Q: In <script type="module">, what is top-level this?
A: undefined.
References
MDN Glossary: Hoisting
MDN Reference: var
MDN Reference:let (TDZ)
MDN Reference: const
MDN Reference: function statement
MDN Guide: Closures
MDN Guide: Memory management
MDN Reference: this
