Technology  /  JavaScript

๐ŸŸจ JavaScript 15 guides ยท updated 2026

Closures, the event loop, promises, and modern ES syntax โ€” the language of the web explained the way it actually behaves at runtime.

JavaScript Variables and Scope: let, const, var, Hoisting, and the TDZ

Variable declaration looks like the most boring topic in any language. In JavaScript it isnโ€™t โ€” because the language carries two generations of scoping rules side by side (var from 1995, let/const from 2015), and because scope is the foundation that closures, modules, and half of all interview questions are built on.

This guide gives you the modern rules, the legacy rules you must recognize, hoisting as it actually works (not the folklore version), and the classic loop bug that demonstrates why any of this matters.


The Modern Rules First: const by Default

Modern JavaScript has a simple declaration policy:

const apiUrl = "https://api.example.com"; // default choice: cannot be reassigned
let retries = 0; // only when reassignment is genuinely needed
retries += 1; // fine
apiUrl = "other"; // TypeError: Assignment to constant variable

Use const unless you will reassign; then use let. Never write var in new code. This isnโ€™t style dogma โ€” each keyword makes a promise to the reader. const says โ€œthis name means one thing for its whole lifeโ€; let says โ€œwatch this one, it changes.โ€ Codebases where 90%+ of declarations are const (which is typical once you write modern JS) are dramatically easier to skim, because mutation โ€” the source of most confusion โ€” is flagged where it happens.

One crucial nuance, the same one Javaโ€™s final has: const freezes the binding, not the value.

const user = { name: "Ada" };
user.name = "Grace"; // fine โ€” the object is still mutable
user = { name: "Alan" }; // TypeError โ€” the *binding* can't be repointed
const list = [1, 2, 3];
list.push(4); // fine

const means โ€œthis variable will always point at the same thing,โ€ not โ€œthis thing canโ€™t change.โ€ For actual immutability you need Object.freeze (shallow) or, more practically, the discipline of not mutating โ€” a theme the objects guide develops.


Scope: Where a Name Exists

JavaScript has three kinds of scope for let/const:

const global = "visible everywhere in this file/script"; // module or global scope
function process() {
const fnScoped = "visible throughout process()"; // function scope
if (true) {
const blockScoped = "visible only inside these braces"; // block scope
}
// blockScoped does not exist here โ€” ReferenceError if accessed
}

Block scope โ€” any { } pair creates one: if bodies, loop bodies, even a bare { } โ€” is the 2015 upgrade. Names live exactly as long as the logic needs them, no longer. Inner scopes see outer names; outer scopes never see inner names. When scopes nest, lookup walks outward โ€” the scope chain:

const a = "outer";
function f() {
const b = "middle";
function g() {
const c = "inner";
console.log(a, b, c); // finds c locally, b one level up, a two levels up
}
g();
}

Two facts about the chain worth engraving:

  1. Itโ€™s determined by where functions are written, not where theyโ€™re called (lexical scoping). You can see what g can access by reading the source โ€” no runtime detective work. This predictability is what makes closures possible: g doesnโ€™t just see b, it retains access to it even if g is passed elsewhere and called after f returns.
  2. Shadowing is legal and occasionally a trap: an inner const x hides an outer x for the inner scopeโ€™s duration. Linters flag suspicious shadowing for good reason โ€” it turns โ€œwhich x is this?โ€ into a real question.

var: The Legacy You Must Recognize

var predates block scope and behaves as if blocks donโ€™t exist โ€” itโ€™s function-scoped only:

function demo() {
if (true) {
var leaky = "I escape the block";
let contained = "I don't";
}
console.log(leaky); // "I escape the block" โ€” var ignored the braces
console.log(contained); // ReferenceError
}

var also re-declares silently (var x = 1; var x = 2; โ€” no error, a typo-hider), and at the top level attaches itself to the global object. Each behavior is a bug class that let/const eliminated. Youโ€™ll still see var constantly โ€” in legacy code, old Stack Overflow answers, and AI-generated code trained on both โ€” so the skill isnโ€™t using it; itโ€™s recognizing its scoping rules on sight while writing const yourself.


Hoisting, Told Straight

The folklore version โ€” โ€œdeclarations get moved to the topโ€ โ€” causes more confusion than it resolves. Hereโ€™s the accurate model:

Before executing a scope, the engine registers every declaration in it. All declared names exist from the scopeโ€™s start. The difference between keywords is what state the name is in before its declaration line runs:

console.log(a); // undefined โ€” var: registered AND initialized to undefined
var a = 1;
console.log(b); // ReferenceError โ€” let: registered but UNINITIALIZED
let b = 2; // (the "temporal dead zone" until this line)
hello(); // "hi!" โ€” function declarations: fully hoisted, body and all
function hello() { console.log("hi!"); }
greet(); // TypeError โ€” it's a const in its TDZโ€ฆ and even after
const greet = () => console.log("hey"); // initialization happens at this line

Unpacking:

Practical distillation: declare before use, and hoisting mostly becomes trivia โ€” except for reading legacy var code, where the โ€œsilent undefinedโ€ behavior explains many a mystery bug.


The Loop Bug: Where Scope Stops Being Abstract

The single most famous demonstration of why let exists:

// with var โ€” the 1995 behavior
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 10);
}
// prints: 3, 3, 3
// with let โ€” the 2015 fix
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 10);
}
// prints: 0, 1, 2

Why var prints 3, 3, 3: there is exactly one i for the whole loop (function-scoped, remember). The three arrow functions all close over that one variable. By the time the timers fire (after the loop finishes โ€” see the event loop), i is 3, and all three callbacks read the same final value.

Why let fixes it: the spec gives each loop iteration its own fresh i binding, copied from the previous iterationโ€™s ending value. Three iterations, three separate variables, three closures each capturing their own. This per-iteration binding was designed specifically because the var version bit so many people for so many years.

The bug generalizes beyond timers: any callback created in a loop โ€” event handlers, promise chains, array of functions โ€” captured the shared var. If you ever maintain pre-2015 code, youโ€™ll meet the historical workaround (an immediately-invoked function creating a scope per iteration) and can now read it for what it is: hand-rolled block scope.


Global Scope: The Place to Stay Out Of

Names at the top level of a script are global โ€” shared by every script on the page, a collision festival. Two modern mitigations:

leaked = "oops"; // assignment without any declaration creates a global (!)

First, strict mode makes that undeclared assignment an error instead of a silent global. You get strict mode automatically inside ES modules and classes โ€” one more reason modules are the default.

Second, modules changed the game: top-level declarations in a module are scoped to that module, not the planet. The modern unit of code organization is the module file, with explicit export/import โ€” global variables have gone from โ€œhow you share codeโ€ to โ€œcode smell with occasional legitimate usesโ€ (feature detection, genuinely app-wide config).

Related habit: keep declarations close to use and scopes small. A const declared 80 lines before its one usage forces the reader to hold it in their head for 80 lines. Block scope lets you declare inside the if/loop where the name actually lives โ€” use that.


Frequently Asked Questions

Why prefer const even for things I never intended to reassign anyway? Because the reader doesnโ€™t know your intentions โ€” the keyword documents them, and the engine enforces them. An accidental reassignment becomes a TypeError at the typo, not a corrupted state three functions later. Zero cost, permanent small dividend.

Is let in a for loop really creating a new variable every iteration โ€” isnโ€™t that slow? The semantics are per-iteration bindings; engines optimize it heavily, and no real application has ever been bottlenecked by it. Correctness first; V8 has your back here.

What about function declarations inside blocks? Historically inconsistent across browsers, later standardized with subtle rules โ€” the practical guidance is simply: donโ€™t. Use const f = () => {} inside blocks; reserve function declarations for the top level of modules/functions.

Does const make objects thread-safe like Javaโ€™s final-plus-immutability? JSโ€™s main thread is single-threaded, so the concurrency angle mostly doesnโ€™t apply โ€” but the reasoning benefits are identical: bindings that canโ€™t change are facts you can rely on while reading. For actual deep immutability, freeze or (better) treat objects as immutable by convention with spread-based updates โ€” covered in objects & spread.

How do I share a variable between two functions? Declare it in their common enclosing scope โ€” thatโ€™s the scope chain working for you. If the functions live in different files, you want module exports, not globals.


Reading Legacy Scope: A Translation Table

Since youโ€™ll read a decade of pre-2015 code (and AI output trained on it), a quick translation guide from old idioms to their modern intent:

You seeIt meantModern form
var self = this;capture this for callbacksarrow function
(function(){ ... })();create a private scopeblock {} or a module
(function(i){ ... })(i) in a loopper-iteration capturelet in the loop head
var x = x || {};default/namespace guardconst x = imported ?? {} / modules
"use strict"; at file topopt into strict modeautomatic in modules & classes

Each row is a workaround for a missing feature, now shipped. Recognizing them as historical scaffolding โ€” not style choices to imitate โ€” is what lets you read old answers on Stack Overflow without importing 2012 into your codebase.

Self-Check

Predict each, then verify in a console:

// 1
console.log(x);
var x = 5;
// 2
console.log(y);
let y = 5;
// 3
const arr = [1, 2];
arr.push(3);
console.log(arr.length);
// 4
for (var i = 0; i < 2; i++) {}
console.log(i);
// 5
for (let j = 0; j < 2; j++) {}
console.log(j);
// 6
const f = () => "a";
function g() { return "b"; }
// which of these could be called ABOVE its definition line?

Answers: (1) undefined โ€” var hoists with undefined. (2) ReferenceError โ€” TDZ. (3) 3 โ€” const binding, mutable array. (4) 2 โ€” var leaks out of the loop. (5) ReferenceError โ€” let is block-scoped to the loop. (6) only g โ€” function declarations hoist fully; the arrow assigned to const sits in the TDZ.

Six for six? Then youโ€™re ready for the payoff chapter: these scoping rules are exactly the machinery behind closures โ€” but first, Data Types and Coercion, where we tame ==, NaN, and the type systemโ€™s famous surprises.