JS let vs const: An Advanced Memory Deep Dive
An advanced exploration of JavaScript let vs const from a memory and engine perspective. Goes beyond syntax to cover temporal dead zone implementation, block scope mechanics in the V8 engine, const immutability vs value mutability, and why const is preferred for performance hints.
Most tutorials cover let and const at the surface level: let allows reassignment, const does not. This article goes deeper — into how the temporal dead zone actually works, what const really guarantees (and what it does not), how engines can use const as an optimization hint, and practical guidelines for choosing between them.
What let and const Actually Guarantee
let x = 1;
x = 2; // ✓ reassignment allowed
const y = 1;
y = 2; // ❌ TypeError: Assignment to constant variable.
// const with objects — the BINDING is constant, not the value
const obj = { a: 1 };
obj.a = 99; // ✓ modifying the object — allowed
obj.b = 100; // ✓ adding properties — allowed
obj = {}; // ❌ reassigning the variable — TypeError
const arr = [1, 2, 3];
arr.push(4); // ✓ mutating the array — allowed
arr = []; // ❌ reassigning — TypeErrorconst means: this variable binding will not be reassigned. It says nothing about whether the value it points to can be mutated.
The Temporal Dead Zone (TDZ) — What Actually Happens
Both let and const are hoisted to the top of their block scope but are NOT initialized until the declaration line is reached. The gap between hoisting and initialization is the Temporal Dead Zone:
console.log(x); // ❌ ReferenceError: Cannot access 'x' before initialization
let x = 5;
console.log(x); // 5
// This differs from var:
console.log(v); // undefined — var is hoisted AND initialized to undefined
var v = 5;What the TDZ Looks Like in Engine Implementation
The JavaScript engine:
- Scans the block scope for all
let/constdeclarations - Allocates storage for them but marks them as "uninitialized"
- Any read or write to an uninitialized binding throws a
ReferenceError - When execution reaches the declaration line, the binding transitions to "initialized"
{
// TDZ for message starts here (hoisted but uninitialized)
console.log(message); // ❌ ReferenceError — in TDZ
const message = "Hello"; // TDZ ends here — now initialized
console.log(message); // "Hello" ✓
}
// message does not exist here — block scopedThe TDZ exists for let and const, not var. This is why you get a ReferenceError (not undefined) for pre-initialization access — it is a signal that the code has a likely bug.
Block Scope vs Function Scope
var is function-scoped; let and const are block-scoped (any {}):
function scopeDemo() {
if (true) {
var funcScoped = "visible outside if";
let blockScoped = "only inside if";
const alsoBlock = "only inside if";
}
console.log(funcScoped); // "visible outside if" ✓
console.log(blockScoped); // ❌ ReferenceError
}
// Block scope in loops — critical for closures
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100); // 3, 3, 3 — shared var i
}
for (let j = 0; j < 3; j++) {
setTimeout(() => console.log(j), 100); // 0, 1, 2 — new let j per iteration
}The let loop behavior is often cited as the most practically important difference from var. Each loop iteration creates a new binding for let — meaning closures capture different variables.
Memory Implications: const as an Engine Hint
When you declare a variable with const, you signal to the JavaScript engine that the binding will not change. Modern engines like V8 can use this information for optimizations:
- No need to check for rebinding: The engine can skip the reassignment check at runtime
- Inline caching benefits: If a
constholds an object, the engine may be more confident about the object's shape not changing (combined with object shape stability rules) - Dead code elimination: If the engine knows a const is a specific primitive at compile time, it can sometimes inline the value
// Engine potentially more confident about this:
const PI = 3.14159;
function circleArea(r) { return PI * r * r; }
// Than this (must re-check PI may have been reassigned):
let pi = 3.14159;
function circleArea2(r) { return pi * r * r; }Modern engines are sophisticated enough that this difference is often negligible in practice — but it is the intent signaling that matters for code readability and maintainability.
Object.freeze — Actual Immutability
To make the value immutable (not just the binding), use Object.freeze():
const config = Object.freeze({
apiUrl: "https://api.example.com",
timeout: 5000,
maxRetries: 3,
});
config.timeout = 9999; // Silently fails (non-strict) or throws (strict)
config.newKey = "value"; // Ignored
console.log(config.timeout); // 5000 — unchanged
// Note: freeze is shallow
const nested = Object.freeze({
outer: "frozen",
inner: { value: 1 }, // inner object is NOT frozen
});
nested.inner.value = 99; // ✓ — inner object was not frozenFor deep immutability, you need recursive freeze or an immutability library.
Comparison Table
| Feature | var | let | const |
|---|---|---|---|
| Scope | Function | Block | Block |
| Hoisted | Yes (initialized to undefined) | Yes (TDZ — uninitialized) | Yes (TDZ — uninitialized) |
| Reassignable | Yes | Yes | No |
| Value mutable | Yes | Yes | Yes (for objects/arrays) |
| Re-declarable | Yes (same scope) | No | No |
| TDZ | No | Yes | Yes |
When to Use Each
Use const by default — it communicates that you do not intend to reassign this variable. This is the clearest signal to both the engine and fellow developers.
Use let when the variable value needs to be updated:
// let — reassigned in loop or accumulation
let total = 0;
for (const item of cart) {
total += item.price; // Reassignment required
}
let currentUser = null;
currentUser = await fetchUser(); // Reassignment requiredNever use var in modern JavaScript unless maintaining legacy code. var's function scope and hoisting to undefined are sources of bugs.
Rune AI
Key Insights
- const prevents rebinding, not mutation: A const object or array can still have its contents changed; only reassigning the variable itself (= new value) is forbidden
- Both let and const are hoisted into the TDZ: They exist in scope from the top of the block but throw ReferenceError if accessed before their declaration line — unlike var which initializes to undefined
- let creates new bindings per loop iteration: Closures inside for loops with let each capture their own iteration variable — solving the classic setTimeout-in-loop bug
- Object.freeze is needed for value immutability: To truly prevent mutation of a const object, wrap it with Object.freeze; freeze is shallow and does not affect nested objects
- Default to const, use let only when needed: Communicates intent to developers and tooling; the lack of accidental reassignment makes code easier to reason about
Frequently Asked Questions
Does const improve performance meaningfully?
Why can't I declare a const without initializing it?
Is let safe to use inside loops with async/await?
Does const prevent accidental reassignment in team code?
Conclusion
const and let share block scope and the temporal dead zone mechanism. The difference is that const prevents rebinding the variable to a new value — it does not prevent mutating the value itself (for objects and arrays). Use const by default for clarity and intent signaling; use let only when reassignment is genuinely needed. Avoid var in all modern code. Understanding the TDZ ensures you never accidentally access variables before their declaration line — a class of bug that var's silent undefined hoisting used to hide.
More in this topic
OffscreenCanvas API in JS for UI Performance
Master the OffscreenCanvas API to offload rendering from the main thread. Covers worker-based 2D and WebGL rendering, animation loops inside workers, bitmap transfer, double buffering, chart rendering pipelines, image processing, and performance measurement strategies.
Advanced Web Workers for High Performance JS
Master Web Workers for truly parallel JavaScript execution. Covers dedicated and shared workers, structured cloning, transferable objects, SharedArrayBuffer with Atomics, worker pools, task scheduling, Comlink RPC patterns, module workers, and performance profiling strategies.
JavaScript Macros and Abstract Code Generation
Master JavaScript code generation techniques for compile-time and runtime metaprogramming. Covers AST manipulation, Babel plugin authorship, tagged template literals as macros, code generation pipelines, source-to-source transformation, compile-time evaluation, and safe eval alternatives.