JavaScript let, const, and var

Every JavaScript variable is created with one of three keywords: var, let, or const. They look similar, but they behave very differently once you look at scope, hoisting, and reassignment rules. Choosing the wrong one is one of the most common sources of subtle bugs in JavaScript, especially inside loops and callbacks. This lesson covers exactly how each keyword works internally, when to use which one, and the mistakes that trip up even experienced developers.

Overview / How it works

var is the original way to declare variables in JavaScript, dating back to 1995. let and const were introduced in ES6 (2015) specifically to fix problems with var. The core difference between them comes down to two things: scope (where the variable is visible) and hoisting behavior (what happens before the declaration line actually runs).

var is function-scoped. A variable declared with var anywhere inside a function — even deep inside an if block or a for loop — belongs to the whole function, not just the block it was written in. let and const are block-scoped: they only exist inside the nearest pair of curly braces { } that contains them, whether that’s an if statement, a loop, or a bare block.

Hoisting is the JS engine’s behavior of processing all variable and function declarations in a scope before executing any code line by line. All three keywords are hoisted, but they are initialized differently. A var declaration is hoisted and initialized to undefined immediately, so you can reference it before its declaration line without an error (you just get undefined). A let or const declaration is hoisted but not initialized — the variable exists in a special state called the Temporal Dead Zone (TDZ) from the start of its scope until the declaration line actually executes. Touching it during the TDZ throws a ReferenceError.

Finally, const adds one more rule on top of let‘s block scoping: once a const binding is assigned a value, that binding can never be reassigned. Importantly, this only locks the variable binding, not the value itself — if the value is an object or array, its contents can still be mutated freely.

Syntax

var name = value;
let name = value;
const name = value; // must be initialized at declaration
Keyword Scope Hoisting behavior Reassignable? Redeclarable in same scope?
var Function (or global) Hoisted, initialized to undefined Yes Yes
let Block Hoisted, stuck in Temporal Dead Zone Yes No
const Block Hoisted, stuck in Temporal Dead Zone No No
  • name — the identifier you’re creating, following JS naming rules (letters, digits, $, _; can’t start with a digit).
  • value — any expression: a literal, a function call, an object, etc. Optional for var and let (defaults to undefined), required for const.

Examples

Example 1: Function scope vs. block scope. This shows the most fundamental difference: a var declared inside an if block “leaks” out to the whole function, while let and const stay trapped inside the block.

function scopeDemo() {
  if (true) {
    var functionScoped = "I am var";
    let blockScoped = "I am let";
    const alsoBlockScoped = "I am const";
  }
  console.log(functionScoped);
  console.log(typeof blockScoped);
  console.log(typeof alsoBlockScoped);
}

scopeDemo();
I am var
undefined
undefined

functionScoped is readable outside the if block because var ignores block boundaries. blockScoped and alsoBlockScoped, however, only exist inside the if block — once we’re outside it, those identifiers have no binding in the function’s scope at all, so typeof reports "undefined" rather than throwing (this is the one safe way to check for a variable that may not exist).

Example 2: Hoisting and the Temporal Dead Zone. Here we deliberately reference each variable before its declaration line to see how the engine handles it.

console.log(typeof usesVar);
console.log(usesVar);
var usesVar = "hoisted var";

try {
  console.log(usesLet);
} catch (error) {
  console.log(error.message);
}
let usesLet = "not hoisted the same way";
console.log(usesLet);
undefined
undefined
Cannot access 'usesLet' before initialization
not hoisted the same way

usesVar is hoisted to the top of its scope and pre-initialized to undefined, so both the typeof check and the direct log succeed before the assignment ever runs. usesLet is also hoisted, but it stays in the Temporal Dead Zone until its let line executes — trying to read it earlier throws a real ReferenceError, which is exactly why we needed the try/catch here.

Example 3: The classic loop-and-closure bug. This is the single most common reason developers switch from var to let in loops.

const varFunctions = [];
for (var i = 0; i < 3; i++) {
  varFunctions.push(() => console.log("var loop:", i));
}
varFunctions.forEach((fn) => fn());

const letFunctions = [];
for (let j = 0; j < 3; j++) {
  letFunctions.push(() => console.log("let loop:", j));
}
letFunctions.forEach((fn) => fn());
var loop: 3
var loop: 3
var loop: 3
let loop: 0
let loop: 1
let loop: 2

Because var i is function-scoped, there is only ever one i shared by every closure pushed into the array; by the time the arrow functions run, the loop has finished and i is 3. With let j, the engine creates a fresh binding of j for every iteration, so each closure captures its own snapshot of the value at that point in the loop.

Example 4: const locks the binding, not the contents.

const user = { name: "Ava", roles: ["admin"] };
user.name = "Ava Stone";
user.roles.push("editor");
console.log(user);

user.roles = [...user.roles, "viewer"];
console.log(user.roles);
{ name: 'Ava Stone', roles: [ 'admin', 'editor' ] }
[ 'admin', 'editor', 'viewer' ]

Reassigning user.name and mutating user.roles with push both work fine — we never touched the user binding itself, only the object it points to. Even replacing user.roles entirely with a new array works, because that’s a property assignment on the object, not a reassignment of the const user variable. Only user = { ... } (rebinding user itself) would throw.

Under the hood

When the JS engine enters a new scope (a function call, a block, a module), it runs a “creation phase” before executing any statements. During this phase it scans the code for declarations. Every var it finds is registered in the nearest function or global environment and immediately set to undefined. Every let and const it finds is registered in the nearest block’s lexical environment, but is marked as “uninitialized” — this is the Temporal Dead Zone. Only when the engine’s execution pointer actually reaches the let/const line does that binding flip to “initialized” and receive its real value. This is also why redeclaring let or const in the same block (for example, let x = 1; let x = 2;) is a SyntaxError caught before the code even runs — the engine sees two declarations targeting the same lexical binding in its creation-phase scan and rejects it outright, whereas var happily lets you “redeclare” the same function-scoped variable any number of times.

Common Mistakes

Mistake 1 — assuming var is block-scoped. As Example 1 showed, a var declared inside an if or for block is fully visible (and mutable) outside it. This leads to accidental variable collisions in large functions where a loop variable named i or a temporary named result silently overwrites something declared earlier in the same function. The fix is simply to declare with let or const instead, so the variable’s lifetime matches the block you actually intended.

Mistake 2 — treating const as “deep immutability.” As Example 4 showed, const only prevents reassigning the variable itself; it does nothing to stop object or array mutation. Developers coming from languages with true immutable constants sometimes assume const user = {...} makes user frozen, then get surprised when a nested field changes elsewhere in the codebase. If you truly need an immutable object, use Object.freeze() (shallow) or a deep-freeze utility on top of constconst alone is not enough.

Mistake 3 — using var loop counters with async callbacks. Example 3’s output (3, 3, 3) is a real production bug pattern: developers write a for (var i ...) loop with a setTimeout or event listener inside, expecting each callback to see its own value of i, and instead every callback sees the final value. Switching the loop counter to let fixes it immediately, since each iteration gets its own binding.

Best Practices

  • Default to const for every variable; it documents that the binding won’t be reassigned and prevents accidental reassignment bugs.
  • Use let only when you know the variable’s value genuinely needs to change (loop counters, accumulators, values reassigned in conditional branches).
  • Avoid var entirely in new code — the only reason to use it is to intentionally demonstrate or work around its legacy hoisting/scoping behavior.
  • Declare variables as close as possible to where they’re first used, in the smallest scope that makes sense — block scoping with let/const makes this natural.
  • If a const-bound object must never be mutated, pair it with Object.freeze() rather than relying on const alone.
  • Never rely on referencing a let/const variable before its declaration line “just working” — treat the TDZ as a hard boundary, not a quirk to route around.

Practice Exercises

  • Write a function that builds an array of 5 functions using a for loop, where calling the nth function logs the number n (0 through 4). Use the correct keyword so each function “remembers” its own index.
  • Predict the output of a snippet that declares const settings = { theme: "dark" }, then runs settings.theme = "light" followed by console.log(settings). Then predict what happens if you instead try settings = { theme: "light" }. Explain the difference in one sentence.
  • Rewrite a function that currently declares a temporary variable with var inside a nested if block and mistakenly reads it outside that block. Convert the declaration to let and explain, in your own words, what error would now occur and why that error is actually useful for catching the bug.

Summary

  • var is function-scoped and hoisted with an initial value of undefined; it can be redeclared and reassigned freely, which is exactly why it’s easy to misuse.
  • let and const are block-scoped and hoisted into the Temporal Dead Zone — accessing them before their declaration line throws a ReferenceError instead of silently returning undefined.
  • const additionally forbids reassigning the variable binding, but does not make objects or arrays immutable — only the binding is locked, not the contents.
  • let/const loops create a fresh binding per iteration, which is why they behave correctly with closures and callbacks where var famously does not.
  • Modern style: reach for const by default, use let when reassignment is genuinely needed, and avoid var in new code.