var vs let vs const in JavaScript
var, let, and const differ in scope, hoisting behavior, and reassignment rules. Here's what each one actually does and when to reach for it.
var, let, and const all declare variables in JavaScript, but they differ in three ways that matter constantly in practice: what scope the variable belongs to, whether it’s usable before its declaration line runs, and whether it can be reassigned. var is function-scoped and quietly forgiving about redeclaration; let and const are block-scoped and much stricter, with const additionally locking the binding against reassignment. Modern JavaScript defaults to const, falls back to let when reassignment is genuinely needed, and treats var as legacy.
Scope: function vs block
var is scoped to the nearest enclosing function (or the global scope, if declared outside any function) — not to the block it’s written in. This means a var declared inside an if block or a for loop leaks out into the surrounding function:
if (true) {
var x = 1;
}
console.log(x); // 1 — leaked out of the if block
let and const are scoped to the nearest enclosing block — anything delimited by { }, including if statements, loops, and standalone blocks:
if (true) {
let y = 1;
}
console.log(y); // ReferenceError — y doesn't exist out here
Block scoping is almost always what you actually want. It keeps a variable’s visibility matched to the code that logically needs it, and it’s why let/const catch a whole category of bugs that var simply allows.
Hoisting and the temporal dead zone
All three are hoisted — technically registered at the top of their scope before the code runs — but they behave differently when accessed before their declaration line.
var declarations are hoisted and initialized to undefined immediately, so reading one before its declaration doesn’t throw, it just gives you undefined:
console.log(a); // undefined
var a = 5;
let and const are hoisted too, but they stay uninitialized until their declaration line actually executes — accessing them before that point throws a ReferenceError rather than silently returning undefined. That window between the start of the scope and the declaration line is called the temporal dead zone:
console.log(b); // ReferenceError: Cannot access 'b' before initialization
let b = 5;
This is a deliberate strictness improvement — a ReferenceError surfaces a bug immediately, instead of letting a stray undefined propagate silently through the rest of a function.
Reassignment and the classic loop bug
var and let can both be reassigned. const cannot be reassigned after its initial assignment — but that only locks the binding, not the value itself: a const object or array can still have its contents mutated, just not be replaced wholesale.
const arr = [1, 2];
arr.push(3); // fine — mutating the array
arr = [4, 5]; // TypeError — reassigning the binding
The interaction between scope and closures produces the single most common var gotcha, usually seen in a loop with an asynchronous callback:
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
// logs: 3, 3, 3 — every callback shares the same function-scoped `i`
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
// logs: 0, 1, 2 — each iteration gets its own block-scoped `i`
With var, every callback closes over the same variable, and by the time any of them run, the loop has already finished and i sits at its final value. With let, each loop iteration creates a fresh binding, so each closure captures the value it actually saw during that iteration. This one difference is responsible for a large share of “why does my loop print the same number every time” bug reports predating let’s introduction.
Redeclaration
var allows redeclaring the same variable name in the same scope without complaint — the second declaration just overwrites the first. let and const throw a SyntaxError on redeclaration in the same scope, which catches accidental name collisions that var would otherwise mask.
Comparison at a glance
var | let | const | |
|---|---|---|---|
| Scope | Function | Block | Block |
| Hoisting behavior | Initialized to undefined | Temporal dead zone | Temporal dead zone |
| Reassignable | Yes | Yes | No (binding only) |
| Redeclarable in same scope | Yes | No | No |
Attaches to global object (window/globalThis) when global | Yes | No | No |
| Recommended default | Avoid in new code | When reassignment is needed | Default choice |
Which one to actually use
The practical guidance most style guides and linters (including ESLint’s prefer-const and no-var rules) converge on: reach for const by default, since most bindings are never reassigned and const documents that intent while catching accidental reassignment as an error. Use let when a variable genuinely needs to change — loop counters, accumulators, values reassigned across branches. Treat var as legacy syntax that exists for backward compatibility with pre-ES2015 code; there’s essentially no situation in new code where var’s function-scoping and loose hoisting behavior is preferable to the block-scoped alternatives.
The takeaway
var is function-scoped, initializes to undefined when hoisted, and can be redeclared and reassigned freely — behavior that made sense when it was JavaScript’s only option, but causes real bugs like loop variables leaking their final value into async callbacks. let and const fix that with block scoping and a temporal dead zone that turns “used before declared” into an immediate error instead of a silent undefined. Default to const, use let when reassignment is actually needed, and there’s little reason to reach for var in code written today.
Tagged
Keep reading
Takina · · 5 min read Promise.all() vs allSettled() vs race() Compared
Promise.all() fails fast, allSettled() waits for every result, and race() returns whichever promise finishes first — how to choose correctly.
Takina · · 4 min read JavaScript Spread vs Rest Operators, Explained
The spread operator (...) expands an iterable into individual elements; the rest operator collects elements back into an array. Same syntax, opposite jobs.
Takina · · 4 min read ResizeObserver API Explained: Watching Element Size
The ResizeObserver API lets JavaScript watch an element's box size and react without polling or resize-event hacks. How it works and when to use it.