Let vs Var in JavaScript: A Practical Guide
Explore why let is preferred over var in JavaScript, with practical examples, scope rules, hoisting, and migration tips to write safer, more maintainable code.
Let and var differ in scope, hoisting, and the temporal dead zone. In modern JavaScript, let helps you prevent global leakage and accidental re-declarations. The takeaway: prefer let for block-scoped variables and reserve var for legacy code or function-scoped patterns.
Block scope and for loops: let vs var
Let and var behave differently inside loops. With var, the loop variable leaks outside the loop. With let, each iteration gets a fresh binding, avoiding surprises in closures.
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
for (let j = 0; j < 3; j++) {
setTimeout(() => console.log(j), 0);
}- The first loop prints 3 three times, showing var's function scope. The second prints 0, 1, 2 because let has block scope per iteration.
Takeaway: Use let for loop indices to avoid unexpected sharing between iterations.
Temporal Dead Zone and initialization
Accessing a let variable before it is initialized triggers a ReferenceError due to the temporal dead zone. In contrast, accessing a var before initialization yields undefined. This difference helps catch mistakes early and prevents unintended behavior.
console.log(a); // ReferenceError: Cannot access 'a' before initialization
let a = 5;console.log(b); // undefined
var b = 5;Why it matters: TDZ enforces deliberate initialization and reduces bugs caused by reading uninitialized variables.
Hoisting myths and how it affects readability
JavaScript hoists declarations to the top of their scope, but only var declarations are initialized. Let declarations are hoisted but not initialized, which contributes to the temporal dead zone. This behavior makes code easier to reason about when you use let throughout your codebase.
console.log(x); // ReferenceError in TDZ
let x = 10;console.log(y); // undefined until initialization
var y = 10;Takeaway: Treat var hoisting as a historical artifact; prefer let to avoid surprises.
Shadowing and redeclarations in blocks vs functions
Let supports block scope, so you can declare the same variable name in nested blocks without clobbering outer bindings. Var ignores block boundaries, which can lead to subtle bugs. See the contrast:
let a = 'outer';
if (true) {
let a = 'inner';
console.log(a); // 'inner'
}
console.log(a); // 'outer'var b = 'outer';
if (true) {
var b = 'inner';
console.log(b); // 'inner'
}
console.log(b); // 'inner' (same binding)Implication: Avoid var if you want predictable scope and reduce accidental overwrites.
Practical migration patterns: refactoring var to let in modules
Migrating large codebases requires a plan. Start with a targeted module and convert var to let where the variable is not intended to leak beyond its block. Use tests to verify behavior. Here is a typical before/after pattern:
// Before
for (var i = 0; i < items.length; i++) {
console.log(i, items[i]);
}
// After
for (let i = 0; i < items.length; i++) {
console.log(i, items[i]);
}/* ESLint config excerpt to enforce no-var */
{
"rules": {
"no-var": "error",
"prefer-const": "error"
}
}Approach: Migrate iterators, closures, and global declarations first; keep edge-case var only where necessary.
Closures and loops: using let fixes captured values in callbacks
Closures in loops behave differently when you use var. The classic pitfall is that all callbacks capture the same loop variable. Replacing with let binds a new value per iteration, fixing the pattern:
var callbacks = [];
for (var i = 0; i < 3; i++) {
callbacks.push(function(){ return i; });
}
console.log(callbacks.map(f => f())); // [3, 3, 3]var callbacksLet = [];
for (let i = 0; i < 3; i++) {
callbacksLet.push(function(){ return i; });
}
console.log(callbacksLet.map(f => f())); // [0, 1, 2]Lesson: Use let to create fresh bindings for each iteration inside loops that spawn asynchronous work.
Tooling and linting: enforcing block-scoped declarations
A robust linting setup helps catch var usage and enforces best practices. Configure ESLint to reject var and prefer let/const:
// eslint config (snippet)
module.exports = {
"env": { "es6": true },
"rules": {
"no-var": "error",
"prefer-const": "error",
"no-unused-vars": "warn"
}
};Next steps: Integrate with your CI to ensure consistency across teams.
Browser support and transpilation: ES6+ required
Let was introduced in ES6 (ES2015). If you need to support older environments, use a transpiler like Babel with @babel/preset-env to compile to a compatible target. This ensures modern code using let runs in legacy browsers.
// Babel config example
{
"presets": ["@babel/preset-env"]
}Tip: Always test in target environments when migrating from var to let.
Best practices and quick guidelines for teams
In new codebases, adopt let (and const for immutable bindings) as the default. Avoid using var unless you have a compelling reason tied to legacy support. Document decisions and create lint rules to prevent regressions. The long-term payoff is fewer scope bugs, easier reasoning, and safer refactoring.
Steps
Estimated time: 15-30 minutes per module depending on size
- 1
Audit your codebase for var usage
Scan modules to identify var declarations and assess their scope and potential leakage.
Tip: Use a static analysis tool to pinpoint risky var usage. - 2
Replace var with let where appropriate
Refactor to block-scoped bindings and run tests to validate behavior.
Tip: Leave var in global/module boundaries only if legacy behavior requires it. - 3
Run linting and tests
Enable ESLint no-var rule and run unit tests to catch regressions.
Tip: Configure lint to fail on var usage. - 4
Review closures and loops
Ensure closures capture correct values using let where needed.
Tip: Prefer let in loops to avoid stale bindings. - 5
Document the migration
Add notes in project docs and commit messages to explain the change.
Tip: Include before/after examples in docs.
Prerequisites
Required
- Required
- Basic JavaScript knowledge: var/let/constRequired
- Modern browser or Node environmentRequired
Optional
- Optional
- Understanding of scope and closuresOptional
Keyboard Shortcuts
| Action | Shortcut |
|---|---|
| Toggle line commentToggle comment on selected lines | Ctrl+/ |
| Format documentAuto-format your JavaScript file | ⇧+Alt+F |
| Go to definitionJump to function/variable definition | F12 |
Questions & Answers
What is the main difference between let and var?
Let provides block scope and TDZ safety, while var uses function scope and can hoist and redeclare in ways that cause surprises.
Let gives block scope, preventing unexpected leaks. Var is function-scoped and can be hoisted, which may cause surprising behavior.
Does let get hoisted like var?
Yes, both are hoisted in memory, but let is not initialized until the declaration, triggering the temporal dead zone if accessed early.
Yes, it's hoisted but not initialized, so you get a TDZ error if you use it before declaration.
Can I still use var in legacy code?
You technically can, but it's better to migrate gradually and isolate var usage, especially in new modules.
You can, but prefer let in new code and plan a gradual migration for legacy code.
Is there a performance difference between let and var?
No meaningful performance difference; the choice is about scope, correctness, and maintainability.
Performance isn't the reason to switch; it's about scope and safer code.
Are there cases where var is still appropriate?
In very old code, or when you rely on function-wide scoping in IIFEs, var might be used, but modern code should prefer let.
Only in legacy patterns; otherwise, use let.
How can I enforce using let across a codebase?
Use ESLint with no-var and related rules to enforce block-scoped declarations.
Configure ESLint to warn or fail on var usage.
What to Remember
- Choose let for block scope when declaring variables
- Avoid hoisting surprises by not using var
- Refactor gradually with linting and tests
- Document migration decisions for team consistency
