var vs let vs const in JavaScript: Scope, the TDZ, and the Bugs Each One Causes
Key takeaways
Default to const, use let only when a binding really changes, and treat var as a legacy keyword. The reasons are concrete: block scope, the temporal dead zone, per-iteration loop bindings, and no leaks onto the global object.
Introduction
“Should I never use var?” is one of the first questions people ask when they learn JavaScript, and the usual answer (“just use const”) is correct but not very convincing on its own. The three keywords differ in four concrete ways: where the binding is visible (scope), when it becomes usable (hoisting and the temporal dead zone), whether it can be reassigned or redeclared, and whether it leaks onto the global object. Each difference maps to a class of bug, and once you have seen those bugs the rule “const by default, let when needed, var never” stops being style advice and becomes a practical defense.
All outputs below were produced with Node.js 24; the browser-specific parts are marked.
Quick comparison
var | let | const | |
|---|---|---|---|
| Scope | Function (or script/module) | Block | Block |
| Before the declaration line | Readable, value undefined | ReferenceError (TDZ) | ReferenceError (TDZ) |
| Reassign | Yes | Yes | No (TypeError) |
| Redeclare in same scope | Yes, silently | No (SyntaxError) | No (SyntaxError) |
| Must be initialized | No | No | Yes |
| Top-level in a classic browser script | Becomes a window property | Does not | Does not |
Loop variable in for (…; …; …) | One binding for the whole loop | New binding per iteration | Not usable (the i++ throws) |
Scope: function vs block
var ignores blocks. An if, for, or bare {} does not create a new scope for it; only a function (or the top level of a script/module) does.
function testVar() {
if (true) {
var x = 10;
}
console.log(x); // 10 — the if-block did not contain it
}
testVar();
let and const are scoped to the nearest pair of braces:
function testLet() {
if (true) {
let x = 10;
const y = 20;
}
console.log(x); // ReferenceError: x is not defined
}
Why this matters in practice: with var, a temporary variable in one branch of a long function is visible (and overwritable) in every other branch. Code that “works” often works only because nobody has reused the name yet.
The switch-statement gotcha
Block scope has one surprise. All case clauses of a switch share one block, so this is a syntax error:
switch (action) {
case 'add':
let result = a + b; // SyntaxError: Identifier 'result' has already been declared
break;
case 'sub':
let result = a - b;
break;
}
The fix is to give each case its own block: case 'add': { let result = a + b; break; }. With var the same code would parse fine and both cases would share one result, which is exactly the kind of silent coupling block scope is designed to prevent.
The loop closure bug
This is the classic reason let exists:
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
With var there is a single i for the whole function. The callbacks run after the loop has finished, and they all read the same variable, which is now 3. For a let loop header, the spec creates a fresh binding for every iteration and copies the current value into it, so each callback closes over its own j.
Before ES2015 the workaround was an IIFE that captured the value as a parameter ((function (i) { setTimeout(() => console.log(i)); })(i);). You will still see that in older codebases; with let it is unnecessary.
The first time I really understood this was not with setTimeout but with event handlers: a legacy page attached click listeners to a list of buttons in a var loop, and every button opened the details of the last item. The code looked correct line by line, and a quick console.log inside the loop printed the right indices, which made it more confusing, because the bug only exists at the moment the handler runs later. Changing one keyword fixed it, but finding it took far longer than it should have.
Why for (const i = 0; …) fails
const works in for…of and for…in because each iteration gets a new binding that is never reassigned:
for (const x of [1, 2]) console.log(x); // 1, 2
In a classic for loop, however, the update expression i++ reassigns the binding:
for (const i = 0; i < 3; i++) {
console.log(i); // 0
}
// then: TypeError: Assignment to constant variable.
The body runs once, then the loop throws. Use let for counter loops.
Hoisting and the temporal dead zone (TDZ)
All three declarations are “hoisted” in the sense that the engine knows about the name from the start of its scope. The difference is what happens if you touch it early.
var is initialized to undefined immediately:
function f() {
console.log(v); // undefined
var v = 1;
console.log(v); // 1
}
That is arguably the worst possible behavior: the early read does not fail, it silently produces undefined, and the bug surfaces somewhere downstream as Cannot read properties of undefined.
let, const, and class bindings exist but stay uninitialized until the declaration line executes. The region between the start of the block and that line is the temporal dead zone:
console.log(a); // ReferenceError: Cannot access 'a' before initialization
let a = 1;
Compare that with a name that does not exist at all, which gives a different message:
console.log(nope); // ReferenceError: nope is not defined
The two messages are a useful diagnostic. “is not defined” means a typo or a missing import; “Cannot access … before initialization” means the name exists but you reached it too early, usually because of ordering.
The TDZ is about time, not position
A function can mention a const declared below it. What matters is whether the declaration has run by the time the function is called:
function g() {
const fn = () => later;
fn(); // ReferenceError: Cannot access 'later' before initialization
const later = 'ok';
fn(); // 'ok'
}
This is how the TDZ error typically shows up in real projects. A common case is circular ES module imports: module A imports a const from module B while B is still being evaluated (because B imported A first), and the access throws “Cannot access ‘X’ before initialization” even though the export plainly exists. Bundlers turn the same situation into the same error at runtime. When you see this message and the declaration is not literally below the usage, look for an import cycle.
typeof is not safe in the TDZ
typeof on an undeclared name returns "undefined", which is why old feature-detection code used it. In the TDZ it throws:
typeof notDeclared; // 'undefined'
typeof b; // ReferenceError: Cannot access 'b' before initialization
let b = 1;
Reassignment and redeclaration
var x = 10;
var x = 20; // allowed, x is now 20
let y = 10;
let y = 20; // SyntaxError: Identifier 'y' has already been declared
const z = 10;
z = 20; // TypeError: Assignment to constant variable.
const w; // SyntaxError: Missing initializer in const declaration
Note the error types. Redeclaration and a missing initializer are SyntaxErrors: they are detected when the file is parsed, so none of the file runs, not even the lines before the mistake. Assigning to a const is a TypeError raised at runtime, only when that line executes. That is why a const reassignment in a rarely taken branch can survive until production, and why the ESLint rule no-const-assign is worth having even though the engine would eventually catch it.
Silent var redeclaration is the dangerous one. In a long script, var user = … near the top and another var user = … in a later section added by someone else will simply overwrite the first. Nothing warns you.
What const does and does not freeze
const fixes the binding, not the value it points to:
const obj = { name: 'Alice' };
obj.name = 'Bob'; // fine
const arr = [1, 2, 3];
arr.push(4); // fine
obj = {}; // TypeError: Assignment to constant variable.
If you need the object itself to be read-only, Object.freeze does that, but only one level deep:
const cfg = Object.freeze({ name: 'Alice', address: { city: 'Seoul' } });
cfg.name = 'Bob'; // ignored in sloppy mode
cfg.address.city = 'Busan'; // works: nested object is not frozen
console.log(cfg.name, cfg.address.city); // Alice Busan
In strict mode (which includes ES modules and class bodies) the first write throws instead of being ignored:
TypeError: Cannot assign to read only property 'name' of object '#<Object>'
That sloppy-mode silence is a real trap: a frozen config object in an old non-module script will quietly refuse writes, and the code that “updated” it carries on with stale values. In practice, const plus a habit of not mutating shared objects gets you most of the benefit; reach for Object.freeze (or a deep-freeze helper) for configuration and constants that must never change.
Does const make code faster?
Not in any way you should rely on. Engines can sometimes exploit the fact that a binding never changes, but the difference is not something to design around. The real payoff is for the reader: const says “this name means the same thing for the rest of the block”, so when you do see let, it is a signal that the value changes and is worth tracking.
var and the global object
In a classic (non-module) browser script, a top-level var becomes a property of window, while top-level let/const do not:
// Browser, classic <script>
var globalVar = 'x';
let globalLet = 'y';
console.log(window.globalVar); // 'x'
console.log(window.globalLet); // undefined
This is more than a curiosity. window already has properties with special behavior, and a global var with the same name collides with them. The best-known example is name: window.name is a string-only property, so
// Browser, classic <script>
var name = 123;
console.log(typeof name); // 'string' — assigned through window.name, coerced to "123"
whereas let name = 123 at the top level creates a separate binding and stays a number. Bugs like this appear when several classic scripts share one page and each declares top-level vars: they all write to the same global object and can clobber each other and built-in properties.
Outside that setting, the rule changes:
- In ES modules (
<script type="module">,.mjs,"type": "module"), top-levelvaris module-scoped and does not touchwindow/globalThis. - In Node.js CommonJS files, each file is wrapped in a function, so a top-level
varis local to the file.var gv = 'x'; console.log(globalThis.gv)printsundefinedin a.cjsfile. (It prints'x'in the REPL or withnode -e, which run as scripts.)
Enforcing it with ESLint
Two rules do nearly all the work: no-var flags every var, and prefer-const flags let bindings that are never reassigned. no-const-assign is already in eslint:recommended. With the flat config format used by ESLint 9 and later:
// eslint.config.js
import js from '@eslint/js';
export default [
js.configs.recommended,
{
rules: {
'no-var': 'error',
'prefer-const': 'error',
},
},
];
Both rules are auto-fixable (eslint --fix), but no-var fixes conservatively: it reports every var yet leaves alone the ones where let would change behavior, such as a variable read before its declaration or a top-level var in a script file (another script might read it through window). Those remaining cases are the ones you convert by hand, and they are exactly where the risk is. On old code I have converted, the safe approach was one file at a time with the tests run between each, because a top-level var that another script depended on as a global breaks only at runtime, in a different file, and the stack trace points at the reader rather than at the change.
Choosing in practice
- Start with
constfor everything: imports, functions, objects, arrays, values computed once. - Switch to
letonly when the variable is actually reassigned: counters, accumulators, a value set in one branch of anif/try. - Do not use
varin new code. When you touch legacy code, convert it deliberately and test, rather than as a drive-by change. - Let the linter enforce it, so reviews can focus on logic instead of keywords.
A small example of the resulting style:
const API_URL = 'https://api.example.com';
const users = []; // the array is mutated, but the binding never changes
let total = 0; // reassigned in the loop, so let
for (let i = 0; i < 10; i++) {
total += i;
}