Hoisting and scoping: A deep dive into your interviewer's favourite question.

Manik AgnishManik Agnish
13 min read

If you have ever given a javascript interview chances are you have been asked about hoisting and variable scoping. You may know the definition but in order to solve those tricky "guess the output" questions you need to have a deep knowledge about how javascript works under the hood. Which is precisely what I am going to cover in this article. So put on your reading glasses, put away your phone and get ready to dive deep, unless of course you are reading this on your phone... don't put away your phone it that case ✌️. But if you are here just for the interview questions then skip to the end and go to town! Anyways let's begin.

Declaring a variable in javascript

Just in case you don't already know there are three ways of declaring a variable in javascript know to mankind (bots too these days 🤖): var, let and const.

var

Ah, var, a relic of JavaScript's earlier days, presumably crafted by developers hammering away on Windows XP using Notepad as their trusty sidekick. It's notorious for turning straightforward code into a head-scratching puzzle.

Variables declared with var can be both re-assigned and re-declared, setting the stage for some truly perplexing bugs. 😬

let

The cooler, younger sibling introduced in ES6. Variables declared with let can be reassigned but not re-declared within the same scope. It's not necessary to initialise them at the point of declaration.

const

Also debuting in ES6, const is for constants. Reassigning them will throw an error. You must initialise const variables when you declare them.

Variable Scope

Scope defines where variables can be accessed within your code. If you declare a variable inside a function, it's only accessible within that function, not outside. Scope comes in three flavours: global, function, and block.

function daScope() {
  const fullName = "Eren Yeager";
  console.log(fullName); // Outputs: Eren Yeager

  if (true) {
    const titan = "mix veg";
    console.log(titan); // Outputs: mix veg
  }

  console.log(titan); // Error: titan is not defined
}

daScope();

This code neatly illustrates block scoping. However, var plays by its own rules, it's function-scoped, so it can be accessed anywhere within the function.

function daScope() {
  const fullName = "Eren Yeager";
  console.log(fullName); // Outputs: Eren Yeager

  if (true) {
    var titan = "mix veg";
    console.log(titan); // Outputs: mix veg
  }

  console.log(titan); // Outputs: mix veg, no error!
}

daScope();

Think that’s quirky? Check out this scenario:

function greetingCreator() {
    var greeting = "Hello";

    if (true) {
        var greeting = "Hi";  // This redeclares and reassigns `greeting` 🤯  
        console.log(greeting); // Output: "Hi"
    }

    function displayGreeting() {
        console.log(greeting);
    }

    displayGreeting(); // Output: "Hi"
}

greetingCreator();

Both outputs are "Hi", demonstrating that var does not recognise block scope, but only function scope. The greeting inside the if block affects the same greeting declared at the function's start.

Variable Shadowing

To avoid the chaos of var, we use let, which enables variable shadowing. Here, if a variable declared in a nested scope has the same name as one in an outer scope, it shadows the outer variable without affecting it.

function greetingCreator() {
    let greeting = "Hello";

    if (true) {
        let greeting = "Hi"; // A new, shadowed `greeting`
        console.log(greeting); // Output: "Hi"
    }

    function displayGreeting() {
        console.log(greeting); 
    }

    displayGreeting(); // Output: "Hello"
}

greetingCreator();

Illegal Shadowing

And then there’s illegal shadowing, brought to you by var, of course:

function greetingCreator() {
    var greeting1 = "Konichiwa";
    let greeting2 = "Hello";

    if (true) {
        let greeting1 = "Hajime Mashite"; // This is fine
        console.log(greeting1); // Output: "Hajime Mashite"

        var greeting2 = "Hi"; // Error: Identifier 'greeting2' has already been declared 
        console.log(greeting2);  
    }

    function displayGreeting() {
        console.log(greeting2); 
    }

    displayGreeting();  
}

greetingCreator();

Javascript Execution Context

JavaScript's execution context is one of the most fundamental concepts to grasp to understand how code executes, particularly how scopes, hoisting, closures, and asynchronous callbacks work. Let's take a detailed look at how JavaScript handles execution context through a step-by-step example.

Consider this JavaScript code snippet:

var x = 10;
let y = 20;

function multiply() {
    var z = x * y;
    console.log(z);
    return z;
}

function display() {
    let a = 5;
    console.log(a);
    multiply();
}

display();
console.log(x);

Here’s how JavaScript processes this code:

When the script loads, JavaScript creates a Global Execution Context. This global context performs two main actions during the creation phase:

  • Variable Environment Creation: Here, all the variable and function declarations are hoisted. Variables declared with var are initialised to undefined, and functions are hoisted with their definitions. Variables declared with let and const remain uninitialised at this point and are in a temporal dead zone.

  • Scope Chain Establishment: Sets up the scope chain, which determines the variable access throughout the code.

  • This Value Determination: For global execution context, this refers to the global object (window in browsers, global in Node.js).

After hoisting, the environment looks something like:

// Global Execution Context (GEC) starts

// Hoisted during the creation phase of GEC
var x = undefined; // 'var' variables are initialized as undefined
let y;             // 'let' and 'const' are in Temporal Dead Zone (TDZ) and not initialized
function multiply() { /* function body is fully hoisted */ }
function display() { /* function body is fully hoisted */ }

// Execution phase of GEC begins
x = 10; // 'x' is now assigned a value
y = 20; // 'y' is assigned a value and comes out of TDZ

function display() {
    // New Function Execution Context for display
    let a = 5; // Local 'let' declaration, only exists within display
    console.log(a); // Logs 5
    multiply();     // Calls multiply, creating a new context for multiply
}

function multiply() {
    // New Function Execution Context for multiply
    var z = x * y;  // 'var' declared and calculated using global x and y
    console.log(z); // Logs 200
    return z;       // Returns 200, multiply context will be popped off the stack after return
}

display();          // Calls display, logs 5, then 200 from multiply
console.log(x);     // Back in GEC, logs 10

// Both multiply and display Function Execution Contexts have been popped off
// Back in the GEC until script ends, at which point GEC is also remove
  • Global Execution Context Setup: At the start, JavaScript engine hoists function declarations and var variables in the global scope.

  • Function Calls & Scope: When display and multiply functions are called, they each create their own execution contexts.

    • display Function Context: Contains its own local variables (e.g., a) and accesses functions and variables from the global scope.

    • multiply Function Context: Accesses global variables (x and y) and has its own local variable (z).

  • Execution Flow: After display calls multiply, multiply computes a result and finishes, popping its context off the stack. Then display finishes, returning control to the global context.

  • End of Execution: After all function contexts resolve, and the script completes, the global context is finally popped off the execution stack.

Hoisting

Hoisting is JavaScript's behaviour of moving variable and function declarations to the top of their scope during the compilation phase, before any code is executed. This means that declarations are processed before any line of code runs, giving the illusion that they are "hoisted" to the top.

Imagine you walk into a classroom and see the teacher’s notes already written on the board. You didn’t see her write them, but somehow they were there before the class even started. That’s hoisting in JavaScript — the interpreter sneakily moves declarations to the top of their scope before any code is run.

But there’s a twist.

Hoisting doesn’t actually move your code physically. What it does is register certain declarations, just the declarations, not the initialisations, during the compilation phase, before your code runs. So when execution begins, JavaScript already knows about the existence of variables and functions, it just might not know their values yet.

Let’s break it down.

var Hoisting

console.log(hoistedVar); // undefined
var hoistedVar = 42;

You might expect this to throw an error, but nope, it prints undefined. Why? Because var hoistedVar is hoisted to the top like this:

var hoistedVar;       // Declaration is hoisted
console.log(hoistedVar); // undefined (default value for hoisted `var`)
hoistedVar = 42;      // Initialization stays where it is

That’s classic var behaviour. Its declarations are hoisted and automatically initialised with undefined.

Function Hoisting

greet(); // "Hello!"

function greet() {
  console.log("Hello!");
}

This works beautifully because function declarations are fully hoisted, both the name and the body. Think of it like JavaScript giving your functions a VIP backstage pass. They’re not just invited early, they come fully dressed and ready to perform.

But here's a curveball:

greet(); // TypeError: greet is not a function

var greet = function () {
  console.log("Hi!");
};

Even though greet is declared with var, it’s hoisted as a variable, not as a function. So what gets hoisted?

var greet;     // Only the declaration is hoisted
greet();       // greet is undefined at this point → TypeError
greet = function () {
  console.log("Hi!");
};

This is why function declarations and function expressions behave differently when it comes to hoisting.

Now enter let and const...

You might think let and const are also hoisted. Technically, they are. But not in the way you'd hope.

console.log(a); // ❌ ReferenceError
let a = 10;

Even though the let a declaration is hoisted, it's not accessible until the line where it's declared. The period between the start of the scope and the actual declaration is known as the Temporal Dead Zone (TDZ).

Variables in the TDZ cannot be accessed, not even to check if they exist.

if (true) {
  console.log(name); // ❌ ReferenceError
  let name = "Zoro";
}

Try to sneak a peek at name before its declaration, and JavaScript slaps you with:

ReferenceError. But... why?

Well, welcome to the Temporal Dead Zone.

Temporal Dead Zone (TDZ)

Sounds scary, right? Like a place in a sci-fi movie where time and logic cease to exist. And honestly, that’s not far off.

The Temporal Dead Zone is the time between a variable being hoisted and being initialised, where accessing it will throw an error.

Here’s the deal:

  • let and const are hoisted, but unlike var, they are not initialised with undefined.

  • Until the line where they are declared is actually executed, they're in the TDZ, a forbidden zone.

  • If you try to access them in that zone, JavaScript throws a ReferenceError, basically saying:
    “Hold your horses! You can’t use this yet.”

Let’s break it down:

function sayHi() {
  console.log(name); // 🚨 ReferenceError
  let name = "Zoro";
}
sayHi();

Why does this error happen?

Even though name is hoisted to the top of the function’s scope, it’s in the TDZ from the beginning of the scope until the line let name = "Zoro"; is actually run.

Now compare this to var:

function sayHi() {
  console.log(name); // undefined
  var name = "Zoro";
}
sayHi();

This time you get undefined, because var is hoisted and initialised with undefined. Not smarter, just sneakier.

The TDZ is Actually a Good Thing

Yes, I said it. The TDZ is your friend. It exists to prevent weird bugs and enforce better coding practices. If a variable is in the TDZ, it means:
“This thing has not been safely initialised yet, don't touch it.”

Without the TDZ, it would be a lot easier to write confusing or broken code. So, while it may feel like JavaScript is being overly strict, it’s actually trying to help you out.

Hoisting interview questions

1. Why doesn’t this function run as expected?

console.log(foo());
var foo = function () {
  return "done";
};

Output:
TypeError: foo is not a function

What’s wrong here?
The var foo is hoisted, but its value is set to undefined at the top. So when foo() is called, it's actually calling undefined().

How to fix it?
Use a function declaration instead of a function expression if you need to call the function before it's defined:

function foo() {
  return "done";
}
console.log(foo());

Or, move the function expression below the console.log if you're using modern patterns like const or let.

2. What’s happening here with let and shadowing?

let a = 10;

function test() {
  console.log(a);
  let a = 20;
}
test();

Output:
ReferenceError

What’s wrong here?
At first glance, you might think a will be 10. But inside the function, let a creates a new a in the block scope, and it’s in the Temporal Dead Zone when console.log(a) runs.

How to fix it?
If you want to access the outer a, don't redeclare it with let inside the function:

let a = 10;

function test() {
  console.log(a); // 10
}
test();

If you need a new a, define it after you've logged the outer one:

function test() {
  console.log(a); // 10
  let aNew = 20;
}

3. What’s going on with these var declarations?

var count = 5;

(function () {
  if (false) {
    var count = 10;
  }
  console.log(count);
})();

Output:
undefined

What’s wrong here?
You might expect count to be 5, but the inner var count is hoisted to the top of the IIFE and initialised as undefined, even though the if block never runs.

So inside the function, count is undefined due to hoisting, not 5 from the outer scope.

How to fix it?
Use let or const to keep block scoping:

var count = 5;

(function () {
  if (false) {
    let count = 10;
  }
  console.log(count); // 5
})();

Or rename variables to avoid this subtle collision.

4. Why is this loop printing unexpected values?

for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 100);
}

Output:
3
3
3

What’s wrong here?
The var i is function-scoped, not block-scoped. So all three callbacks share the same i and print its final value after the loop ends.

How to fix it?

Use let for block scoping:

for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 100);
}

Or use an IIFE to capture the current i:

for (var i = 0; i < 3; i++) {
  (function (j) {
    setTimeout(() => console.log(j), 100);
  })(i);
}

5. Why does this throw when you access foo inside bar?

function foo() {
  console.log("outer foo");
}

function bar() {
  console.log(foo);
  function foo() {
    console.log("inner foo");
  }
}
bar();

Output:
[Function: foo]

Wait... it doesn’t throw?

Actually no, but here's the twist, this often catches people off-guard.

What’s really happening?
The function foo inside bar is hoisted to the top of bar's scope. So when console.log(foo) runs, it’s actually logging the function itself, not calling the outer foo.

Follow-up:
Change console.log(foo) to foo() what happens?

function bar() {
  foo(); // this calls the inner foo, not the outer one
  function foo() {
    console.log("inner foo");
  }
}
bar();

The output is "inner foo" because the inner declaration overshadows the outer one.

Fix (if you want the outer one):
Rename the inner function or avoid redeclaring functions with the same name.

6. Why does this function behave differently based on where it’s declared?

"use strict";

if (true) {
  function test() {
    console.log("block");
  }
}
test();

Output:
In some environments: ReferenceError

Why is this tricky?
Function declarations inside blocks are a grey area in JavaScript. In strict mode, function declarations are not block-scoped in older engines, and behaviour can vary between browsers.

In modern JavaScript, this should throw a ReferenceError, because test is scoped only inside the if block.

How to fix it?
Use function expressions or const if declaring inside a block:

if (true) {
  const test = () => console.log("block");
  test();
}

Or, declare it outside the block if it needs wider visibility.

That’s a Wrap

I know you are having a blast but the show must end here, I have to feed my pet (virtual pet 🫠). On the bright side you can now tackle any hoisting and scoping related interview questions like a pro. Next time we meet, I’ll let you in on another JavaScript mystery. Spoiler alert: PROMISES!

0
Subscribe to my newsletter

Read articles from Manik Agnish directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Manik Agnish
Manik Agnish

I am a front-end software developer and user experience designer based in India. I have worked extensively on creating inspiring web experiences and wish to share my knowledge through these blogs.