Skip to content
JavaScript js fundamentals 4 min read

Variables: var, let & const

Variables are named bindings that hold values your program works with. JavaScript gives you three ways to declare them — const, let, and var — but they behave very differently around scope, reassignment, and hoisting. Choosing the right one is one of the highest-leverage habits you can build: it makes code easier to reason about and quietly prevents a whole category of bugs.

Declaring variables

A declaration introduces a name; an assignment gives it a value. With const you must do both at once, because a const binding can never be reassigned. With let you can declare first and assign later, and reassign as often as you like.

const pi = 3.14159; // declared and assigned together
let count = 0; // can change later
count = count + 1;

let label; // declared without a value -> undefined
label = "ready";

Tip: const does not make a value immutable — it makes the binding immutable. You cannot point const user at a new object, but you can still mutate the object it points to (user.name = "Ada"). Use Object.freeze() if you need the contents to be read-only too.

const user = { name: "Grace" };
user.name = "Ada"; // OK — mutating the object
// user = {};        // TypeError — reassigning the binding

Scope: block vs function

let and const are block-scoped: they exist only inside the nearest pair of { } — a function body, an if, a for loop, or even a bare block. var is function-scoped: it ignores blocks and leaks out to the enclosing function (or the global scope).

function scopeDemo() {
  if (true) {
    let blockScoped = "let";
    var functionScoped = "var";
  }
  console.log(functionScoped); // accessible
  console.log(blockScoped);    // ReferenceError
}

This difference is most painful in loops. With var, every iteration shares one binding, so closures capture the final value. With let, each iteration gets a fresh binding.

const fns = [];
for (let i = 0; i < 3; i++) {
  fns.push(() => i);
}
console.log(fns.map((fn) => fn()));

Output:

[ 0, 1, 2 ]

Swap let for var and every function logs 3, because they all close over the same i.

Reassignment and redeclaration

let can be reassigned but not redeclared in the same scope. const allows neither. var allows both, which is exactly why it hides bugs — you can accidentally clobber an existing variable without any error.

let total = 10;
total = 20;        // OK
// let total = 30; // SyntaxError: already declared

var name = "first";
var name = "second"; // no error, silently overwrites

Hoisting and the temporal dead zone

All three declarations are hoisted — the engine knows about them before the line that declares them runs. The difference is what happens if you access them early. var declarations are hoisted and initialized to undefined, so reading one before its assignment gives undefined. let and const are hoisted but not initialized; the span between the top of the block and the declaration is the temporal dead zone (TDZ), and reading the variable there throws.

console.log(early); // undefined (var is hoisted + initialized)
var early = 1;

console.log(later); // ReferenceError — TDZ
let later = 2;

Warning: The TDZ is a feature, not a bug. It turns “I used this before I defined it” from a silent undefined into a loud error you can fix immediately.

Comparison at a glance

Featureconstletvar
ScopeBlockBlockFunction
ReassignableNoYesYes
Redeclarable (same scope)NoNoYes
HoistedYes (TDZ)Yes (TDZ)Yes (as undefined)
Must initialize at declarationYesNoNo
Creates global property (top level)NoNoYes

The modern rule

The community has converged on a simple decision procedure: reach for const by default. If you discover the binding genuinely needs to change — a loop counter, an accumulator, a value built up conditionally — switch that one to let. Avoid var in new code entirely; its function scoping and silent redeclaration are legacy behavior kept only for backward compatibility.

const apiUrl = "https://api.example.com";

let retries = 0;
while (retries < 3) {
  retries += 1;
}

const message = `Tried ${retries} times`;
console.log(message);

Output:

Tried 3 times

This works identically in modern browsers and in Node.js — let and const have been standard since ES2015 and are safe to use everywhere today.

Best Practices

  • Default to const; only downgrade to let when you actually reassign the binding.
  • Never use var in new code — block scoping and TDZ catch more mistakes earlier.
  • Declare variables as close as possible to where they are first used, not at the top of the function.
  • Remember const freezes the binding, not the value — use Object.freeze() for read-only data.
  • Give variables descriptive, intention-revealing names instead of relying on comments.
  • Prefer a fresh let/const per loop iteration over a shared mutable counter when closures are involved.
Last updated June 1, 2026
Was this helpful?