MediumWhy JavaScript Keeps Lying About Variable Values | by Aronno Ahsan | Medium
Sitemap
Aronno Ahsan

Aronno Ahsan

Software Engineer. Tech & Business Enthusiast.

Why JavaScript Keeps Lying About Variable Values

9 min readAug 4, 2026

--

Press enter or click to view image in full size
Why JavaScript Keeps Lying About Variable Values

If you have ever written JavaScript for real, you have experienced a peculiar type of gaslighting. I wrote a sequence of instructions top-to-bottom, then ran the script, and watched it output a value that seems impossible!

Just think about a scenario, you console log a variable before declaring it, it prints undefined. Then you refactor the same code using let syntax, and suddenly it crashes with a ReferenceError! Sometimes we experience this weird type of bug if we write a for loop with var and then watch JavaScript print the exact same number in every iteration!

After facing all of these issues, it feels like JavaScript is constantly lying to me about what variables contain and when they exist!

But as I grow older, I understand JavaScript is not lying or deceiving us. We are just reading the script before the engine finishes setting the stage. We humans read code linearly, line by line, but JavaScript does not. Before a single line of code executes, the JavaScript engine performs an invisible setup phase that moves variables, builds scope hierarchies, and alters how memory is held.

Let’s pull up and expose what the JavaScript engine is actually doing when we are not looking.

The Illusion of Line-by-Line Execution

I’ll write 2 code examples. If JavaScript truly executed code line-by-line in strict order, both examples would throw immediate errors on line 1.

Example 1 : The Uncanny Undefined

console.log(magic); // Output: undefined
var magic = "Now you see me";

How can magic log undefined when the line declaring var magic has not run yet! If the variable did not exist yet, it should throw a ReferenceError: magic is not defined.

Example 2 : The Fake Fatal Error

console.log(realMagic); // Output: ReferenceError: Cannot access 'realMagic' before initialization
let realMagic = "Now you don't";

Now here it throws a ReferenceError, but notice the error message: “Cannot access ‘realMagic’ before initialization.”. It does not say that “realMagic is not defined.” The engine knows realMagic exists! But it refuses to let us touch it.

Both of these reveal a fundamental truth: JavaScript is a two-pass engine. It scans our entire file first to allocate memory before it ever goes back to execute the code.

The Hidden Stage Manager — Execution Context & Environment

To understand where the values of the variable come from, first we have to understand the Execution Context.

When JavaScript runs code, it does it inside an execution context. The Execution Context is like a box which contains the running code and everything needed to execute it.

Press enter or click to view image in full size

The are 2 primary types of Execution Contexts:

  1. Global Execution Context (GEC): Created once, when the script runs.
  2. Function Execution Context (FEC): Created every time the function is invoked.

Phases of Execution Context

Every Execution Context is built in 2 distinct phases:

Phase 1: The Creation Phase (Memory Allocation)

The engine scans through the current context. It ignores actual value assignments and logic. Instead, it looks for variable declarations (var, let, const) and function declarations, storing them inside an internal data structure called the Environment Record.

Phase 2: The Execution Phase (Code Execution)

The engine returns to the top of the context and begins executing code line-by-line, assigning values, running conditional checks and invoking functions.

var name = "Alice";

function greet() {
var greeting = "Hello " + name;
return greeting;
}

greet();

Here is what JS engine actually does to the code

[Creation Phase - GEC]
- Memory allocated for 'name' -> initialized as undefined
- Memory allocated for 'greet' -> points to full function definition

[Execution Phase - GEC]
- 'name' assigned "Alice"
- 'greet()' invoked -> Creates new Function Execution Context (FEC)

[Creation Phase - FEC (greet)]
- Memory allocated for 'greeting' -> initialized as undefined

[Execution Phase - FEC (greet)]
- Resolves 'name' from Outer Environment
- 'greeting' assigned "Hello Alice"
- Returns value, FEC popped off Call Stack

Memory allocation happens during the Creation Phase, so variable slots exist in memory before the line of the Execution Phase ever runs.

Variable Creation Paradox — Hoisting & Temporal Dead Zone (TDZ)

A common misconception is hoisting means physically moving code lines to the top. But it’s absolutely wrong.Hoisting is simply a mental model for how a Creation Phase allocates memory before execution.

However, var, let, const and function statements handle the Creation Phase very differently.

Declarations vs Expressions

sayHi(); // Works! Output: "Hi!"
sayBye(); // TypeError: sayBye is not a function

function sayHi() {
console.log("Hi!");
}

var sayBye = function() {
console.log("Bye!");
};

Here SayHi() works but sayBye() crashes! But why?

  • Function Declarations (function sayHi) are fully hoisted during the creation phase. The entire function body is stored in the memory immediately.
  • Function Expressions (var sayBye = fn) follow the var rules. The variable sayBye is hoisted and initialised with undefined. So during execution, we are trying to invoke undefined(), and that’s why it throws a TypeError.

The Temporal Dead Zone (TDZ)

Many developers believe that let and const are not hoisted. Even one of my most favorite YouTuber Kyle from Web Dev Simplified said that let and const are not hoisted. But he is wrong. The truth is let and const are hoisted. But they are not initialised. Only var is initialised with undefined.

let x = "outer";

function testTDZ() {
// If 'let' was not hoisted, this console.log would look up the scope
// chain and log "outer".
console.log(x); // ReferenceError: Cannot access 'x' before initialization
let x = "inner";
}

testTDZ();

If inner let x were not hoisted, the JS engine would not know it existed yet inside testTDZ() and would log “outer” from the outer scope.

Get Aronno Ahsan’s stories in your inbox

Join Medium for free to get updates from this writer.

Instead, during the Creation Phase of testTDZ(), the JS engine registers x in the local Environment Record. However, x is placed in an uninitialised state. The period between the start of the scope and the line where let x is declared is called Temporal Dead Zone (TDZ). Touching a variable in the TDZ will trigger a ReferenceError.

The Scope Chain Trap — Scope & Variable Resolution

What happens when a variable is not inside the immediate local environment record? The engine begins traversing the scope chain.
JavaScript uses Lexical Scoping. Here Lexical means that scope is determined by where you physically typed your code in the file, not where or how the function is called at runtime. Every Environment Record holds an internal pointer: the outer environment reference.

const user = "Global User";

function outer() {
const user = "Outer User";

function inner() {
// How does JS resolve 'user'?
console.log(user); // Output: "Outer User"
}

inner();
}

outer();

When console.log(user) executes inside inner():

  1. Engine checks inner()’s local Environment Record. Is the user there? No.
  2. Engine follows inner()’s Outer Environment Reference to outer(). Is the user there? Yes! Value resolved: “Outer User”.
  3. The lookup stops immediately. The global user (“Global User”) is masked, this is known as variable shadowing.
Press enter or click to view image in full size

Memory Mirage — Call Stack vs Heap

Where do these variables physically live while the script runs?

JavaScript uses 2 memory storage regions:

  1. The Call Stack: Fast & structured memory. It holds primitive values like number, string, boolean, null, undefined etc.
  2. The Memory Heap: Large, unstructured memory pool. It holds reference types like Objects, Arrays, functions etc.
// Stack vs Heap Storage

// Primitive: Value stored directly on the Stack
let age = 27;
let newAge = age; // Copies the raw value (27)
age = 28;

console.log(age); // 28
console.log(newAge); // 27 (Unchanged)

// Reference Type: Object stored in Heap, pointer address stored on Stack
const config = { theme: "dark" };
const userConfig = config; // Copies the MEMORY ADDRESS pointer, not the object!

config.theme = "light";

console.log(userConfig.theme); // Output: "light"
Press enter or click to view image in full size

This explains why mutating a const object is completely valid in JavaScript.

const user = { name: "Ahsan" };
user.name = "Aronno"; // Valid! Object mutated in Heap.
// user = {}; // TypeError! Reassigning the pointer address on the Stack.

The const only protects the variable binding on the Stack. It does not freeze the data residing in the heap.

The Ultimate Plot Twist — Closures & Retained Scope

In my opinion Closures are JavaScripts most powerful yet most misunderstood feature. When execution context, lexical scoping and heap memory collide the closures are formed.

When a function finishes execution, its function execution context (FEC) pooped off from the call stack and garbage collector destroyed its local variables.

Closures occur when an inner function is exposed outside its parent function, maintaining a reference to its parent’s Lexical Environment even after the parent Execution Context has been destroyed.

Here is an example:

function createCounter() {
let count = 0; // Lives in memory via Closure

return function() {
count++;
return count;
};
}

const counter = createCounter();
// createCounter() has executed and popped OFF the Call Stack!

console.log(counter()); // Output: 1
console.log(counter()); // Output: 2

How can counter() increment count when createCounter() is no longer running?

When createCounter runs, the inner function is created with an invisible internal property [[Environment]]. This property holds a direct link to the Lexical Environment where the inner function was born.

When createCounter() pops off the Call Stack, the engine sees that count is still referenced by the inner function’s [[Environment]]. Instead of garbage collecting count, the engine moves count into the heap.

Press enter or click to view image in full size

The Infamous Loop Lie

I’ve faced this in many interviews and it felt quite tricky!

// The var problem
for (var i = 0; i < 3; i++) {
setTimeout(() => {
console.log("var i:", i);
}, 100);
}
// Output:
// var i: 3
// var i: 3
// var i: 3

They asked me why it prints 3,3,3 instead of 0,1,2?

  1. First of all var is function scoped or global scoped. It’s not block-scoped.
  2. The loop runs to completion synchronously, During Creation/Execution Phase, only one i variable binding is created for the entire loop context.
  3. By the time setTimeout callbacks run 100ms later, the for loop has finished and that shared single i variable holds 3.
  4. All three closure callbacks point to the exact same i binding!

If we use block scoped let to declare variable:

// The let fix
for (let j = 0; j < 3; j++) {
setTimeout(() => {
console.log("let j:", j);
}, 100);
}
// Output:
// let j: 0
// let j: 1
// let j: 2

Because let is block scoped, the engine creates a brand new Lexical Environment Record for every single iteration of the loop. Each timer callback closes over its own unique j variable instance created for the specific iteration.

Accidental Memory Leaks

Closures forcefully keep parent variables alive in the Heap memory, they can easily become a silent source of memory leaks. It occurs when an inner function retains a reference to variables in its parent lexical environment long after that parent execution context has completed and it’s no longer needed. As inner function holds invisible [[Environment]] property to those outer variables, the JS engine’s garbage collector cannot free that heap memory.

This frequently happens in long lived applications when closures containing heavy state are unintentionally attached to global event listeners, intervals, or DOM elements without being cleaned up.

// MEMORY LEAK: The closure keeps 'heavyDataset' alive in Heap memory forever
function setupAnalytics() {
const heavyDataset = new Array(10000000).fill("Heavy Data"); // Consumes megabytes of RAM

const handleScroll = function() {
// This closure references 'heavyDataset', locking the massive array in memory
// as long as the window scroll listener exists!
console.log(`User scrolled. Dataset status: ${heavyDataset.length} items loaded.`);
};

window.addEventListener("scroll", handleScroll);
}

setupAnalytics(); // 'setupAnalytics' finishes execution, but 'heavyDataset' cannot be garbage-collected!

To prevent this type of issues

  1. We must always clean up event listeners by using removeEventListener and clear timers using clearInterval when a component unmounts or an UI element is destroyed.
  2. If we have to write a long-lived closure with a heavy reference, we must manually nullify it. Marking it null makes it eligible for garbage collectors to clean it up.
  3. Also avoiding defining massive variables in the outer scope of the callback is a good coding practice.

Conclusion

Once we align our mental model with how the JavaScript engine really works, then JS stops feeling deceptive and starts becoming predictable. Modern JavaScript is much more predictable than its older versions. Using modern let and const instead of var eliminates scope bleeding, and unexpected undefined hoisting bugs. Even simple coding practices like placing let and const at the top of the code block prevents accidental references during TDZ. When we are debugging any confusing code, we can ask ourselves a simple question: what did the engine put into memory during the pass before this line ran?. It might save a lot of time and a lot of our hair.

Lets get connected on LinkedIn: https://www.linkedin.com/in/aronnoahsan/

--

--

Software Engineer. Tech & Business Enthusiast.

See more from me