In tech interviews across Bangalore and Pune, interviewers love to hand candidates a sheet of paper with six lines of JavaScript and ask: "What gets printed to the console first?"
Candidates with 3 years of experience in mass-recruiter firms freeze. They know how to render a button in React, but they cannot explain whether setTimeout runs before or after a resolved Promise.
JavaScript is a single-threaded, non-blocking, concurrent language. That sentence sounds like an impossible contradiction until you understand the V8 runtime engine. When you understand the call stack, memory references, closures, and the event loop, JavaScript stops being unpredictable.
The Execution Context and the Call Stack
JavaScript is single-threaded: it has one call stack and can only execute one line of code at any given microsecond.
When your script starts, the engine creates a Global Execution Context. Every time you invoke a function, the engine creates a new Function Execution Context and pushes it onto the top of the call stack (Last In, First Out).
function calculateTax(subtotal) {
return subtotal * 0.18; // GST calculation
}
function processOrder(amount) {
const tax = calculateTax(amount);
return amount + tax;
}
const total = processOrder(1000);
console.log(total); // 1180
Here is what the call stack does in memory:
- Push
global()onto the stack. - Invoke
processOrder(1000): pushprocessOrderonto the stack. - Inside
processOrder, invokecalculateTax(1000): pushcalculateTaxonto the stack. calculateTaxcompletes, returns 180, and pops off the stack.processOrdercompletes, returns 1180, and pops off the stack.console.log(1180)is pushed, executes, and pops off.
If a function calls itself indefinitely without a termination condition, stack frames accumulate until the runtime aborts with RangeError: Maximum call stack size exceeded. That is stack overflow.
Primitive Values vs Heap References
One of the most frequent bugs in frontend applications stems from misunderstanding how JavaScript stores data in RAM.
- Primitives (Number, String, Boolean, null, undefined, Symbol, BigInt): Stored directly on the execution stack. They are copied by value.
- Objects (Arrays, Plain Objects, Functions): Allocated on the memory heap. The variable on the stack merely holds a memory pointer (reference) pointing to that heap location.
// Primitives: copy by value
let priceA = 500;
let priceB = priceA;
priceB = 999;
console.log(priceA); // 500 (unaffected)
// Objects: copy by reference
const cartA = { items: ["Mouse"], total: 800 };
const cartB = cartA; // Copies memory address, not the object!
cartB.total = 1500;
console.log(cartA.total); // 1500! cartA was mutated
To clone objects safely without mutating shared references, use structured cloning: const cartB = structuredClone(cartA); or shallow copying with object spread: const cartB = { ...cartA };.
Lexical Scope and Closures
A closure is a function bundled together with references to its surrounding state (lexical environment). In simple terms: a function remembers where it was born.
When a parent function finishes executing and pops off the call stack, its local variables normally get garbage collected. But if an inner function still holds a reference to those variables, the runtime retains them in memory.
function createRateLimiter(maxCalls) {
let callCount = 0; // Private state kept alive by the closure
return function (apiEndpoint) {
if (callCount >= maxCalls) {
throw new Error(`Rate limit exceeded for ${apiEndpoint}`);
}
callCount += 1;
return `Calling ${apiEndpoint} (Request ${callCount}/${maxCalls})`;
};
}
const limiter = createRateLimiter(2);
console.log(limiter("/api/orders")); // Calling /api/orders (Request 1/2)
console.log(limiter("/api/orders")); // Calling /api/orders (Request 2/2)
// limiter("/api/orders"); // Throws Error: Rate limit exceeded
The variable callCount cannot be modified directly from outside. You achieved true private encapsulation without classes or global variables. React hooks like useState rely entirely on this closure mechanism under the hood.
The Event Loop: Microtasks vs Macrotasks
If JavaScript is single-threaded, how does it fetch data from an API without freezing the entire browser UI? The answer is the Event Loop working alongside Web APIs (or libuv in Node.js).
When you call fetch() or setTimeout(), the JavaScript engine hands that job off to browser background threads. Once the network request finishes or the timer expires, the browser places the callback into a task queue.
There are two primary queues with strict priority rules:
- Microtask Queue: Promises (
.then(),catch(),await),queueMicrotask(), andMutationObserver. - Macrotask Queue:
setTimeout(),setInterval(), DOM event callbacks, and network I/O callbacks.
The Golden Execution Rule: The event loop runs synchronous code on the call stack first. Once the call stack is completely empty, it executes ALL pending microtasks before executing a single macrotask.
console.log("1: Script Start");
setTimeout(() => {
console.log("2: Macrotask (setTimeout)");
}, 0);
Promise.resolve().then(() => {
console.log("3: Microtask (Promise)");
});
console.log("4: Script End");
// Output Order:
// 1: Script Start
// 4: Script End
// 3: Microtask (Promise)
// 2: Macrotask (setTimeout)
Notice why setTimeout(..., 0) does NOT run immediately. It is placed into the macrotask queue. Even with a 0-millisecond delay, it must yield the CPU to the synchronous code and the entire microtask queue.
var vs let vs const: The Temporal Dead Zone
Never use var in modern JavaScript. Here is why:
varis function-scoped and hoisted to the top of its scope, initialized withundefined. You can access it before its declaration line, producing silent bugs.letandconstare block-scoped (constrained by curly braces{}). They are also hoisted, but remain in the Temporal Dead Zone (TDZ) until the line of declaration executes. If you touch them early, the engine throws aReferenceErrorimmediately.
// Legacy behavior with var
console.log(legacyToken); // undefined (silent bug)
var legacyToken = "xyz_123";
// Modern behavior with const
// console.log(modernToken); // Throws ReferenceError: Cannot access 'modernToken' before initialization
const modernToken = "xyz_456";
Engineering Practice Checklist
- Default to
const. Useletonly when you know the variable will be reassigned. Never usevar. - Use
===(strict equality) instead of==(loose equality) to avoid implicit type coercion bugs. - Use closures for state encapsulation when building reusable utility functions.
- When debugging asynchronous code, trace whether your logic belongs to a microtask or a macrotask.
When you understand the runtime machinery, JavaScript changes from a confusing scripting language into a predictable engineering tool.
