Topics
v5.0.0
On this page · sections

JavaScript Variables, Scope & Hoisting

Can you explain where a binding lives, when it becomes usable, and why var still 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:

“var is function-scoped and let is 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 environment

Questions

Scope

  1. var vs let vs const
  2. What Is Hoisting?

Initialization

  1. Temporal Dead Zone
  2. const Binding vs Value Mutation

Senior-Level Follow-ups

  1. Why Does for (let i...) Capture Per Iteration?
  2. Module Scope vs Script Globals
  3. 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-scoped
  • let → block-scoped
  • const → 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 var escape an if block?
  • Why can’t let be 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 alsoBlockScoped

The 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 2

The 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 === 3

All 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 → callback

Each 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

“var is function-scoped, while let and const are block-scoped. The important distinction for closure puzzles is that var gives the callbacks one shared binding, whereas for (let ...) provides per-iteration bindings. In new code I default to const, use let when rebinding is required, and generally avoid var.”

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 → TDZ

Function 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

DeclarationBinding established before execution?Initialized before declaration line?Readable before declaration line?
varYesYes, to undefinedYes
letYesNoNo — TDZ
constYesNoNo — TDZ
Function declarationYesYesYes
const fn = () => {}YesNoNo — TDZ

Interview Answer

“Hoisting is a useful shorthand for the fact that bindings are established before normal statement execution. But declarations behave differently: var is initialized to undefined, let and const remain uninitialized in the TDZ, and function declarations are initialized so they can be called before their source position. A function expression assigned to const follows const binding 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 ReferenceError

Problem

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 → ReferenceError

This distinction is frequently used in interview questions.

TDZ Is Not “Failed Hoisting”

A weak explanation is:

“let isn’t hoisted.”

A stronger explanation is:

“let and const bindings 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 let or const binding and its initialization. The binding is already part of the lexical environment, but reading it before initialization throws ReferenceError. typeof only 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:

“const makes 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; // TypeError

The 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", }; // TypeError

because the binding itself is being reassigned.

The Important Distinction

const │ └── protects the binding │ ├── reassignment → ❌ │ └── object mutation → ✅

So:

const config = { theme: "dark", }; config.theme = "light"; // allowed

but:

config = {}; // TypeError

What 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 possible

The 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

“const prevents reassignment of the binding; it does not make the referenced value immutable. An object stored in a const binding 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 2

A simplistic explanation is:

“let is 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 binding

The 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 === 3

All 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 = 2

This 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 observe 0, 1, and 2. With var, the callbacks share one function-scoped binding whose final value is 3.”

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; // undefined

A 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; // undefined

ES 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 var is 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.AppConfig

but a migration changes the code into an ES module:

const AppConfig = {};

That does not automatically create:

window.AppConfig

If 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 var can create a property on the global object, while top-level let and const do not. ES module top-level bindings are module-scoped and don’t become window properties. 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 request

The actual lifetime is closer to:

Server process ↓ module loaded ↓ module state reused across requests

Therefore:

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 components

For 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

StateAppropriate lifetime
Component UI stateComponent instance
React context stateReact tree
Request metadataRequest
Authenticated user for SSRRequest
Database connection poolProcess/application
ConfigurationProcess/application
CacheApplication/system
Module singletonProcess/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 singleton

Senior-Level Signal

A strong answer doesn’t stop at:

“let is block-scoped.”

It explains:

“The declaration establishes a binding in a lexical environment. let and const bindings are uninitialized until their declaration is evaluated, which creates the TDZ. Closures capture bindings rather than snapshots of primitive values, and for (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.

Last updated on