Scope, hoisting and the temporal dead zone

Know exactly where each variable can be read, why some names exist before their line runs, and why let and const refuse early access.

  • Beginner
  • 7 min read
  • Updated 17 Oct 2026

What you will learn

  • Predict variable scope
  • Explain the temporal dead zone
  • Explain how hoisting treats function declarations, var, let and const differently
  • Explain why var is avoided in modern code

Before you start

Scope is the set of rules that decides where a variable name can be used. Most "x is not defined" errors and many confusing bugs come from misreading scope, so this lesson makes the rules explicit. It also explains hoisting, the reason some names exist before the line that declares them.

Three kinds of scope

JavaScript has three scopes you meet every day.

ScopeCreated byVariables that live there
GlobalThe script or module itselfDeclarations at the top level
FunctionEvery function callParameters, and anything declared inside the function
BlockAny { } block: if, for, while, a bare blocklet, const and class declared inside the block

A variable can be read in the scope where it is declared and in every scope nested inside it. It cannot be read from outside.

JavaScript
const site = 'Geeks2Smart';          // global scope

function greet(name) {               // name: function scope
  const greeting = 'Hello';          // function scope
  if (name) {
    const message = `${greeting}, ${name}, welcome to ${site}`; // block scope
    console.log(message);
  }
  // console.log(message);  ReferenceError: message is not defined
}

greet('Asha');
// → Hello, Asha, welcome to Geeks2Smart

Inside the if block, message can see greeting (one level out) and site (two levels out). Outside the block, message does not exist.

The scope chain

When JavaScript meets a name, it looks in the current scope first, then the enclosing scope, and so on out to the global scope. This path is the scope chain. The first match wins, and if nothing matches you get a ReferenceError.

Because the first match wins, an inner variable with the same name shadows the outer one:

JavaScript
const level = 'outer';

function show() {
  const level = 'inner';
  console.log(level);
}

show();             // → inner
console.log(level); // → outer

Shadowing is legal but makes code harder to read. Prefer distinct names.

Scope is decided by where the code is written, not where it is called from. This is called lexical scope, and it is the foundation of closures in the next lesson.

JavaScript
const colour = 'blue';

function printColour() {
  console.log(colour);
}

function run() {
  const colour = 'red';
  printColour();
}

run(); // → blue

printColour was written next to the global colour, so that is the one it sees, even though run calls it.

Blocks and loops

Each loop iteration with let gets its own fresh binding. That matters whenever a function created inside a loop runs later:

JavaScript
for (let i = 0; i < 3; i += 1) {
  setTimeout(() => console.log(i), 0);
}
// → 0
// → 1
// → 2

Each callback remembers its own i. You will see shortly what happens with var instead.

Hoisting

Before any code in a scope runs, JavaScript scans the scope and registers every declaration in it. This is called hoisting. The names exist from the top of the scope, but what you can do with them early depends on how they were declared.

DeclarationRegistered at the top?Value before its line runs
function name() {}YesThe whole function, ready to call
varYesundefined
let, const, classYesNone: reading it throws a ReferenceError

Function declarations

Function declarations are hoisted with their body, so you can call them above the line where they appear:

JavaScript
console.log(double(4)); // → 8

function double(n) {
  return n * 2;
}

This lets you put the main logic at the top of a file and helper functions below it. Function expressions and arrow functions assigned to const follow the const rules instead:

JavaScript
console.log(triple(4));
// ReferenceError: Cannot access 'triple' before initialization

const triple = (n) => n * 3;

The temporal dead zone

let, const and class are hoisted too, but they are not initialised until their declaration line runs. The stretch of code between the start of the scope and that line is the temporal dead zone (TDZ). Touching the variable inside it throws:

JavaScript
function checkout() {
  console.log(total);
  // ReferenceError: Cannot access 'total' before initialization
  const total = 42;
}

checkout();

The error message tells you the variable exists but is not ready yet. Compare the message when a name is not declared anywhere:

JavaScript
console.log(nothingHere);
// ReferenceError: nothingHere is not defined

The TDZ also explains this surprise, where the inner declaration shadows the outer one for the whole block, including the lines above it:

JavaScript
const price = 10;

{
  console.log(price);
  // ReferenceError: Cannot access 'price' before initialization
  const price = 20;
}

It is "temporal" because it is about time, not position. A function may mention a let variable declared later, as long as the function is not called until after the declaration has run:

JavaScript
function report() {
  console.log(count);
}

let count = 5;
report(); // → 5

The TDZ is a feature. It turns a silent bug, reading a value before it is set, into a loud error on the exact line.

Why var is avoided

var is the original way to declare variables. You will meet it in older code and tutorials, so you need to recognise its behaviour, but you should not write it. It differs from let in three ways, and each one causes bugs.

1. var ignores blocks. It is scoped to the whole function (or the global scope), not the block:

JavaScript
function test() {
  if (true) {
    var leaked = 'still here';
  }
  console.log(leaked);
}

test(); // → still here

2. var hoists as undefined. Reading it early gives undefined instead of an error, so mistakes go unnoticed:

JavaScript
console.log(score); // → undefined
var score = 10;

3. One var is shared by the whole loop. Here every callback sees the same i, which has reached 3 by the time they run:

JavaScript
for (var i = 0; i < 3; i += 1) {
  setTimeout(() => console.log(i), 0);
}
// → 3
// → 3
// → 3

At the top level of a classic script, var also creates a property on the global object (window in browsers), where it can collide with other scripts. let and const do not.

Use const by default and let when you must reassign. With these two, scope matches the braces you can see, and early access fails loudly.

Global scope and modules

Top-level declarations in a classic <script> are shared by every classic script on the page. Two files that both declare const count at the top level will clash with a SyntaxError. ES modules, covered in lesson 34, fix this: each module has its own top-level scope, and you share values deliberately with export and import.

Until then, keep the global scope small. Wrap related code in functions so most variables live in function or block scope.

Summary

  • Scope decides where a name can be used: global, function or block.
  • Lookup walks the scope chain outwards from where the code is written; the first match wins.
  • Hoisting registers declarations before code runs. Function declarations are usable early; var is undefined early.
  • let, const and class throw a ReferenceError in the temporal dead zone before their line runs.
  • Avoid var: it ignores blocks, hides early reads and shares one binding across loop iterations.

Further reading