On this page · sections
JavaScript Variables, Scope & Hoisting
Can you explain where a binding lives, when it becomes usable, and why
varstill appears in JavaScript output puzzles?
Scope and hoisting are some of the fastest ways to distinguish syntax familiarity from a real understanding of JavaScript execution.
At junior level, the discussion is usually:
“
varis function-scoped andletis block-scoped.”
At senior level, the interviewer is usually testing something deeper:
- Which environment owns the binding?
- When is the binding created?
- When is it initialized?
- What happens when a closure captures it?
- How does the lifetime differ between browser scripts, ES modules, and server processes?
- Is the state lifetime compatible with SSR request isolation?
The important mental model is:
Source code
↓
Execution context
↓
Environment / binding created
↓
Binding initialized
↓
Statements execute
↓
Closures may retain the environmentQuestions
Scope
Initialization
Senior-Level Follow-ups
- Why Does
for (let i...)Capture Per Iteration? - Module Scope vs Script Globals
- Where Does SSR Request State Belong?
Scope
1. var vs let vs const
Why This Matters
Scope determines where a binding can be accessed and is fundamental to understanding closures, loops, callbacks, and state lifetime.
The classic distinction is:
var→ function-scopedlet→ block-scopedconst→ block-scoped
But a senior-level answer should go further: scope describes the environment in which the binding is resolved; it does not describe whether the value itself is mutable.
Problem
Explain:
- Why does
varescape anifblock? - Why can’t
letbe accessed outside the block? - Why does
for (var i...)produce the same value in every callback? - Why does
for (let i...)produce different values? - Which declaration should you use in production code?
Example: Function Scope vs Block Scope
function scopeDemo() {
if (true) {
var functionScoped = "var";
let blockScoped = "let";
const alsoBlockScoped = "const";
}
console.log(functionScoped);
// "var"
console.log(blockScoped);
// ReferenceError
console.log(alsoBlockScoped);
// ReferenceError
}
scopeDemo();The if block creates a lexical scope for let and const, but var belongs to the surrounding function’s variable environment.
A useful mental model:
function scopeDemo()
│
├── var functionScoped
│
└── if block
├── let blockScoped
└── const alsoBlockScopedThe Classic Loop + Closure Puzzle
for (var i = 0; i < 3; i++) {
setTimeout(() => {
console.log("var", i);
}, 0);
}
for (let j = 0; j < 3; j++) {
setTimeout(() => {
console.log("let", j);
}, 0);
}var 3
var 3
var 3
let 0
let 1
let 2The important point is not simply that “let is block-scoped.”
The important point is what each closure captures.
Conceptually:
var
│
└── one binding: i
│
├── callback → i
├── callback → i
└── callback → i
After loop:
i === 3All callbacks reference the same i.
For let, the loop semantics provide a distinct binding for each iteration:
Iteration 0 → binding j = 0 → callback
Iteration 1 → binding j = 1 → callback
Iteration 2 → binding j = 2 → callbackEach callback therefore observes its iteration’s value.
Production Guidance
Prefer:
const value = ...when the binding does not need reassignment.
Use:
let value = ...when the algorithm genuinely needs to reassign the binding.
Avoid var in new code unless you have a deliberate reason to use its legacy semantics.
Interview Answer
“
varis function-scoped, whileletandconstare block-scoped. The important distinction for closure puzzles is thatvargives the callbacks one shared binding, whereasfor (let ...)provides per-iteration bindings. In new code I default toconst, useletwhen rebinding is required, and generally avoidvar.”
2. What Is Hoisting?
Why This Matters
“Hoisting” is useful terminology, but it can become misleading when treated as if JavaScript literally moves declarations to the top of the file.
A more precise explanation is:
Before executing statements, JavaScript establishes the bindings required by the relevant execution context and initializes them according to their declaration kind.
The exact ECMAScript mechanics involve execution contexts and environment records, so “hoisting” is best understood as a convenient mental model rather than a literal source-code transformation.
Problem
Explain the difference between:
console.log(a);
var a = 1;and:
foo();
function foo() {
console.log("ok");
}Then compare them with:
console.log(b);
let b = 2;var
console.log(a);
// undefined
var a = 1;Conceptually:
var a; // binding initialized to undefined
console.log(a);
a = 1;The declaration establishes the binding before the console.log executes, but the assignment happens later.
Function Declaration
foo();
// "ok"
function foo() {
return "ok";
}Function declarations are available before their textual position within the applicable scope because the binding is initialized with the function during environment setup.
let and const
console.log(b);
// ReferenceError
let b = 2;The binding exists, but it is not initialized yet.
This distinction is critical:
Binding created
↓
Binding initialized?
│
├── yes → readable
│
└── no → TDZFunction Declaration vs Function Expression
This is another common interview trap:
foo();
// works
function foo() {
return "ok";
}versus:
foo();
// ReferenceError
const foo = () => {
return "ok";
};The second example declares a const binding whose initialization occurs at the assignment statement. The function expression itself is not available through that binding before initialization.
Comparison
| Declaration | Binding established before execution? | Initialized before declaration line? | Readable before declaration line? |
|---|---|---|---|
var | Yes | Yes, to undefined | Yes |
let | Yes | No | No — TDZ |
const | Yes | No | No — TDZ |
| Function declaration | Yes | Yes | Yes |
const fn = () => {} | Yes | No | No — TDZ |
Interview Answer
“Hoisting is a useful shorthand for the fact that bindings are established before normal statement execution. But declarations behave differently:
varis initialized toundefined,letandconstremain uninitialized in the TDZ, and function declarations are initialized so they can be called before their source position. A function expression assigned toconstfollowsconstbinding semantics.”
Initialization
3. Temporal Dead Zone
Why This Matters
The Temporal Dead Zone, or TDZ, explains why a let or const binding can technically exist in a scope but still throw when accessed.
Consider:
{
console.log(x);
let x = 1;
}The important distinction is:
x is in scope
↓
but x is not initialized
↓
reading x throws ReferenceErrorProblem
When does a let binding become readable?
And does typeof protect you from a TDZ error?
Example
{
console.log(x);
// ReferenceError
let x = 1;
}The TDZ begins when execution enters the relevant lexical scope and ends when the declaration’s initialization has been evaluated.
typeof Does Not Bypass TDZ
Compare:
typeof undeclaredVariable;
// "undefined"with:
{
typeof x;
// ReferenceError
let x = 1;
}These are different situations.
For an undeclared identifier:
identifier does not exist
↓
typeof → "undefined"For a TDZ binding:
binding exists
↓
binding is uninitialized
↓
typeof → ReferenceErrorThis distinction is frequently used in interview questions.
TDZ Is Not “Failed Hoisting”
A weak explanation is:
“
letisn’t hoisted.”
A stronger explanation is:
“
letandconstbindings are established before execution reaches their declaration, but they remain uninitialized until their declaration is evaluated. Access during that period is a TDZ violation.”
That explanation also makes the following behavior intuitive:
{
// TDZ for x
let x = 10;
// x is now initialized
console.log(x);
// 10
}Interview Answer
“The TDZ is the period between creation of a
letorconstbinding and its initialization. The binding is already part of the lexical environment, but reading it before initialization throwsReferenceError.typeofonly safely handles genuinely undeclared identifiers; it does not bypass the TDZ.”
4. const Binding vs Value Mutation
Why This Matters
One of the most common JavaScript interview inaccuracies is:
“
constmakes a variable immutable.”
It doesn’t.
const makes the binding non-reassignable.
It does not recursively freeze the value referenced by that binding.
Primitive Example
const count = 1;
count = 2;
// TypeErrorThe binding cannot be pointed at another value.
Object Example
const user = {
name: "Ada",
};
user.name = "Grace";
console.log(user.name);
// "Grace"This works because the binding still points to the same object:
user ───────→ { name: "Grace" }This does not:
user = {
name: "Alan",
};
// TypeErrorbecause the binding itself is being reassigned.
The Important Distinction
const
│
└── protects the binding
│
├── reassignment → ❌
│
└── object mutation → ✅So:
const config = {
theme: "dark",
};
config.theme = "light";
// allowedbut:
config = {};
// TypeErrorWhat About Object.freeze()?
const user = {
name: "Ada",
};
Object.freeze(user);
user.name = "Grace";In strict mode, attempting to modify a frozen object’s property throws.
However:
Object.freeze()is shallow.
For example:
const state = {
user: {
name: "Ada",
},
};
Object.freeze(state);
state.user.name = "Grace";
// still possibleThe outer object is frozen, but the nested object is not.
Senior-Level Perspective
Immutability is a data-modeling and application-level contract, not something const automatically provides.
Depending on the use case, you might use:
- immutable update patterns
- structural sharing
- defensive copies
- recursive freezing
- immutable data structures
- framework-specific state management conventions
Interview Answer
“
constprevents reassignment of the binding; it does not make the referenced value immutable. An object stored in aconstbinding can still be mutated.Object.freeze()provides shallow runtime immutability, so nested structures require additional handling if deep immutability is actually required.”
Senior-Level Follow-ups
5. Why Does for (let i...) Capture Per Iteration?
This is where a scope question becomes a closure and execution-model question.
Consider:
for (let i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i);
}, 0);
}0
1
2A simplistic explanation is:
“
letis block-scoped.”
That’s incomplete.
The important behavior is that for with a lexical declaration has per-iteration bindings.
Conceptually:
Iteration 1
└── environment
└── i = 0
└── callback captures this binding
Iteration 2
└── environment
└── i = 1
└── callback captures this binding
Iteration 3
└── environment
└── i = 2
└── callback captures this bindingThe closures therefore do not all reference one shared i.
Compare With var
for (var i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i);
}, 0);
}Conceptually:
One binding
└── i
Callbacks:
├── callback → i
├── callback → i
└── callback → i
After loop:
i === 3All callbacks observe the same binding.
The Historical var Fix
Before let existed, developers commonly created a new function scope manually:
for (var i = 0; i < 3; i++) {
(function (copy) {
setTimeout(() => {
console.log(copy);
}, 0);
})(i);
}Each invocation creates a new binding:
IIFE #1 → copy = 0
IIFE #2 → copy = 1
IIFE #3 → copy = 2This is essentially manually creating the per-iteration capture that let makes much easier.
Interview Answer
“
for (let ...)has per-iteration lexical bindings. Each iteration gives closures a binding associated with that iteration, so callbacks observe0,1, and2. Withvar, the callbacks share one function-scoped binding whose final value is3.”
6. Module Scope vs Script Globals
This distinction becomes important when working with browser globals, bundlers, legacy applications, and ES modules.
Classic Script
Given a classic browser script:
<script>
var scriptVar = 1;
let scriptLet = 2;
</script>At the top level of a classic global script:
window.scriptVar;
// 1
window.scriptLet;
// undefinedA top-level var declaration in a classic global script participates in the global environment in a way that exposes it as a property of the global object.
Top-level let and const do not create corresponding global-object properties.
ES Modules
With:
<script type="module">
var moduleVar = 1;
let moduleLet = 2;
const moduleConst = 3;
</script>these are module-scoped bindings:
window.moduleVar;
// undefined
window.moduleLet;
// undefined
window.moduleConst;
// undefinedES modules have their own module scope.
This is one reason legacy code can behave differently after being migrated from classic scripts to modules.
Important Nuance
Don’t generalize:
“Top-level
varis always global.”
That’s false.
For example:
function example() {
var value = 1;
}value is function-scoped, not global.
Likewise, bundlers and module systems can change how source-level declarations are packaged and exposed.
Why This Matters in Frontend Systems
Suppose legacy code expects:
window.AppConfigbut a migration changes the code into an ES module:
const AppConfig = {};That does not automatically create:
window.AppConfigIf a global API is intentionally required, expose it explicitly:
window.AppConfig = AppConfig;But this should be a deliberate integration boundary rather than an accidental side effect.
Interview Answer
“A classic browser script and an ES module have different top-level scoping semantics. In a classic global script, top-level
varcan create a property on the global object, while top-levelletandconstdo not. ES module top-level bindings are module-scoped and don’t becomewindowproperties. This matters when migrating legacy global code to modules.”
7. Where Does SSR Request State Belong?
This is where scope and lifetime become an architecture problem.
Consider a server module:
let currentUser = null;
export function setCurrentUser(user) {
currentUser = user;
}
export function getCurrentUser() {
return currentUser;
}This may look harmless in a browser application.
On a server, however, the module is generally loaded once and reused by the process.
That means:
Server process
│
├── Request A
│ └── currentUser = Alice
│
├── Request B
│ └── currentUser = Bob
│
└── Request C
└── currentUser = ?The module-level binding is not request-scoped.
If concurrent requests mutate the same singleton state, one request can observe state belonging to another request.
That’s a correctness and potentially a security problem.
The Wrong Mental Model
A common mistake is to think:
Server module
↓
one requestThe actual lifetime is closer to:
Server process
↓
module loaded
↓
module state reused across requestsTherefore:
let currentUser;at module scope is usually the wrong place for request-specific state.
Better Model
Request-specific state should live in a request-scoped mechanism.
Depending on the stack, that could be:
HTTP request
↓
request object
↓
request-scoped context
↓
React application tree
↓
server-rendered componentsFor React-based SSR, a context/provider or framework-supported request context can be appropriate when the state needs to flow through the component tree.
The key principle is:
The lifetime of the state should match the lifetime of the request.
State Lifetime Matrix
| State | Appropriate lifetime |
|---|---|
| Component UI state | Component instance |
| React context state | React tree |
| Request metadata | Request |
| Authenticated user for SSR | Request |
| Database connection pool | Process/application |
| Configuration | Process/application |
| Cache | Application/system |
| Module singleton | Process/application |
The senior-level skill is not merely knowing “global state is bad.”
It’s understanding whether the lifetime of the state matches the lifetime of the data.
Interview Answer
“In SSR, module-level state is generally process-scoped rather than request-scoped. Request-specific information such as the authenticated user should live on the request or in a request-scoped context that flows through the render tree. Otherwise concurrent requests can accidentally share mutable state.”
Final Interview Strategy
When you see a scope or hoisting question, don’t immediately memorize the output.
Walk through it systematically.
1. What execution context am I in?
↓
2. Which environment owns the binding?
↓
3. When is the binding created?
↓
4. When is it initialized?
↓
5. Can the current code legally read it?
↓
6. Is a closure capturing the binding?
↓
7. What is the binding's lifetime?
↓
8. Does that lifetime match the application's requirement?Quick Reference
var
├── function-scoped
├── initialized to undefined
├── can be read before assignment
└── loop callbacks can share one binding
let
├── block-scoped
├── created before execution reaches declaration
├── uninitialized during TDZ
└── for-loop provides per-iteration bindings
const
├── block-scoped
├── uninitialized during TDZ
├── binding cannot be reassigned
└── referenced objects can still be mutated
function declaration
├── binding established during environment setup
└── callable before its source position
ES module
├── own module scope
├── top-level bindings are not window properties
└── module state can still have process lifetime on SSR
SSR request state
├── request-specific
├── should be request-scoped
└── should not live in a mutable module singletonSenior-Level Signal
A strong answer doesn’t stop at:
“
letis block-scoped.”
It explains:
“The declaration establishes a binding in a lexical environment.
letandconstbindings are uninitialized until their declaration is evaluated, which creates the TDZ. Closures capture bindings rather than snapshots of primitive values, andfor (let ...)provides per-iteration bindings. On the server, module scope can outlive an individual request, so request-specific state needs a request-scoped lifetime.”
That demonstrates that you understand JavaScript not just as a collection of syntax rules, but as a language with binding semantics, lexical environments, closures, and explicit state lifetimes.