Skip to content
ExponaBLOG
JAVASCRIPT & TYPESCRIPT · DAY 003 / 100

Execution context and hoisting: best practices explained

Why some variables exist before their line runs.

Expona Admin7 min read

Execution context and hoisting: Understanding JavaScript's invisible setup phase

When you run JavaScript code, the engine doesn't simply execute your statements top to bottom. Before a single line runs, JavaScript performs a setup phase called the execution context, which includes hoisting—a mechanism that moves certain declarations to the top of their scope. Understanding this invisible process will save you hours of debugging and help you write more predictable code.

What is execution context?

Every time JavaScript executes code, it creates an execution context—a conceptual container that holds all the variables, functions, and other bindings for that scope. This happens in two phases:

  1. Creation phase: The engine scans your code and registers all declarations without executing assignments.
  2. Execution phase: Your code runs line by line, and variables get their actual values.

During the creation phase, hoisting occurs. Different declaration types behave differently during this phase, and that's where confusion usually starts.

Function declarations are hoisted fully

When you declare a function with the function keyword, the entire function—declaration and body—is hoisted to the top of its scope. This means you can call a function before it appears in your source code.

typescript
// You can call greet before it's declared
console.log(greet("Alice")); // Output: "Hello, Alice"

function greet(name: string): string {
  return `Hello, ${name}`;
}

During the creation phase, the engine registers the entire greet function in memory. When execution reaches the console.log line, greet already exists and is ready to call.

In real-world scenarios, I've seen teams leverage this when organizing large files. You can place helper functions at the bottom for readability, knowing they'll be available throughout the file. However, this can also make code harder to follow for newcomers who don't expect functions to be used before they're defined.

typescript
// Large file example
export async function processOrder(orderId: number): Promise<void> {
  const order = await fetchOrder(orderId);
  validateOrder(order);
  await persistOrder(order);
}

// Helper functions below
function validateOrder(order: Order): void {
  if (!order.items.length) throw new Error("Empty order");
}

async function fetchOrder(id: number): Promise<Order> {
  // API call
}

async function persistOrder(order: Order): Promise<void> {
  // Database write
}

While this works, I recommend placing helpers at the top or using separate modules for clarity. The fact that it works doesn't mean it's the best practice.

var is hoisted and initialized as undefined

Variables declared with var are hoisted, but unlike function declarations, only the declaration is moved—not the initialization. During the creation phase, var variables are initialized to undefined.

typescript
console.log(count); // Output: undefined (not a ReferenceError)
var count = 5;
console.log(count); // Output: 5

This is what happens under the hood:

typescript
// What you write
console.log(count);
var count = 5;

// What the engine does (conceptually)
var count; // Hoisted, initialized as undefined
console.log(count); // undefined
count = 5;

I avoid var in modern code for this exact reason. The gap between hoisting and assignment creates a window where your variable exists but holds undefined. This is rarely what you intend.

I encountered this in a legacy e-commerce system I inherited. A payment calculation function used var extensively, and during refactoring, a conditional assignment was accidentally removed. The variable existed as undefined, silently corrupting calculations. Switching to let and const caught this immediately with a ReferenceError.

let and const live in the temporal dead zone

Variables declared with let and const are hoisted too, but they're not initialized during the creation phase. Instead, they enter a state called the temporal dead zone (TDZ)—a period from the start of the scope until the declaration is encountered during execution.

typescript
console.log(userEmail); // ReferenceError: Cannot access 'userEmail' before initialization
let userEmail = "user@example.com";

This looks different from var, which would print undefined. With let and const, you get an explicit error pointing you to the problem.

The temporal dead zone includes the entire block scope before the declaration:

typescript
function checkTDZ(): void {
  if (true) {
    console.log(status); // ReferenceError
    let status = "active"; // TDZ ends here
  }
}

Even though status might be declared in an outer scope, the inner let declaration creates a new binding that shadowing the outer one. Throughout the inner block, that inner binding is in the TDZ until its declaration line is reached.

typescript
let status = "inactive"; // Outer scope

function checkTDZ(): void {
  console.log(status); // ReferenceError: Cannot access 'status' before initialization
  // Not "inactive"—the inner binding shadows the outer one
  let status = "active";
}

In production code, this behavior protects you. I've built trading platforms where price calculations depend on tightly scoped values. The TDZ ensures I can't accidentally reference a stale binding from an outer scope. It's stricter, but that strictness catches bugs early.

Block scope and function scope matter

Hoisting behavior depends on scope. Function declarations are hoisted to the top of their function scope. var is also function-scoped, while let and const are block-scoped (inside if, for, while, or {} blocks).

typescript
function demonstrateScope(): void {
  if (true) {
    var oldStyle = "function scoped";
    let newStyle = "block scoped";
  }
  
  console.log(oldStyle); // "function scoped" - accessible here
  console.log(newStyle); // ReferenceError - not accessible here
}

This difference is why var can leak out of loops unexpectedly:

typescript
for (var i = 0; i < 3; i++) {
  // loop body
}
console.log(i); // 3 - still accessible

for (let j = 0; j < 3; j++) {
  // loop body
}
console.log(j); // ReferenceError - j is block-scoped to the loop

In a CRM system I built, event handlers were created in a loop using var. All handlers captured the same variable, and by the time they executed, the loop had finished. Switching to let or const fixed it without needing closures or IIFEs.

Write code top-down and avoid relying on hoisting

Just because something hoists doesn't mean you should rely on it. The best approach is to write code that reads top-down: declare before you use.

typescript
// Good: Clear intent, easy to follow
const config = { timeout: 5000 };
const logger = createLogger(config);

function createLogger(cfg: Config): Logger {
  return new Logger(cfg);
}

export async function startServer(): Promise<void> {
  logger.info("Starting...");
}

// Avoid: Relies on hoisting, harder to follow
export async function startServer(): Promise<void> {
  logger.info("Starting...");
}

function createLogger(cfg: Config): Logger {
  return new Logger(cfg);
}

const config = { timeout: 5000 };
const logger = createLogger(config);

In large teams, predictable code structure prevents bugs. When I onboard developers to a project, I want them to find values and functions where they're used, not buried below.

Use hoisting knowledge defensively: when you see strange behavior, check if hoisting is the culprit. But write your own code assuming top-down execution.

Common mistakes

  • Assuming var and let behave the same: Using var and expecting ReferenceError on early access, only to get undefined. Always use let or const.
  • Forgetting the temporal dead zone: Shadowing an outer variable with let or const, then accessing it before the declaration. The TDZ applies to the entire block, not just the space before the declaration.
  • Using hoisting as a feature: Calling functions defined later in the file feels clever but confuses readers. Your future self will thank you for top-down code.
  • Mixing function declarations with arrow functions: Arrow functions are not hoisted. const fn = () => {}; cannot be called before the line runs.
  • Ignoring block scope in loops: Using var in loops and expecting each iteration to have its own binding. Use let or const instead.

Key takeaways

  • Execution context has a creation phase where hoisting occurs, then an execution phase where code runs.
  • Function declarations are fully hoisted and can be called before their definition appears in code.
  • var is hoisted and initialized as undefined, creating a window where it exists but holds no meaningful value.
  • let and const live in the temporal dead zone until their declaration line executes, protecting you from stale bindings.
  • Write code top-down for clarity: declare variables and functions before you use them, rather than relying on hoisting.
  • Always prefer let and const over var in modern JavaScript and TypeScript.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles