Explaining Browser JavaScript in Interviews: Queues, Heap, Leaks, Closures
A structured tour of the browser runtime for interview prep: call stack, task and microtask queues, heap and garbage collection, leaks, delegation, prototypes and closures.
This article is published in English.
Sources: #1 FrontEnd Concepts For Interviews — Javascript Fundament
Front-end interviews rarely reward memorized one-liners. Interviewers probe whether you can explain what the engine does with your code: where a callback waits, why an object outlives its function, why one listener on a container can replace hundreds. This guide covers those fundamentals in the order they build on each other, so you can reason through an unfamiliar question instead of reciting an answer. A good self-test: after each section, explain the idea aloud as if to a colleague who has never heard of it.
The call stack: where your code executes
Every JavaScript engine runs code on a call stack, a last-in, first-out structure you can picture as an array where you only push and pop at the end. Calling a function pushes a frame for its execution context; returning pops it. There is only one such stack on the main thread, so while a frame runs, nothing else does. A long synchronous loop freezes rendering and input until it finishes.
Why a network request does not freeze the page
If only one thing runs at a time, why doesn't the page stall while waiting for a backend? Because JavaScript is not the one waiting. Picture a screen that crunches a large dataset and then calls several APIs in sequence. Calling fetch() hands the work to a Web API provided by the browser, which handles the network outside the stack. Your function returns a pending promise at once, and the user keeps interacting.
When the response arrives, the promise settles and its .then() callbacks are scheduled as microtasks, which run once the stack is empty. Strictly, the network completion is delivered through the browser's task machinery, and what enters the microtask queue are the promise reactions. The interview-level point is that your continuation is queued, not run inline.
Task queue versus microtask queue
Timers take a different path: a setTimeout() callback goes into the task queue, also called the macrotask queue. The event loop's rule is that after the current task finishes and the stack is empty, it drains the whole microtask queue before picking the next task. This function schedules one of each; the numbers in the messages give away the order.
function triggerBothQueues() {
console.log("1. Synchronous: Code starts on Call Stack");
// Enqueue a Macrotask (Task Queue)
setTimeout(() => {
console.log("4. Task Queue (Macrotask): setTimeout callback executed");
}, 0);
// Enqueue a Microtask (Microtask Queue)
Promise.resolve().then(() => {
console.log("3. Microtask Queue: Promise.then callback executed");
});
console.log("2. Synchronous: Code ends on Call Stack");
}
triggerBothQueues();
The two synchronous lines print first, then the promise callback, then the timer. A delay of 0 means "queue as soon as possible", not "run now", and the microtask still wins. A common follow-up: a microtask that schedules another microtask joins the same queue and runs before any timer, which is why an endless microtask chain can starve rendering.
The heap: where objects live
The stack holds execution contexts; the heap holds the data they point to. Objects, arrays, functions and anything handled by reference live on the heap, while local primitives such as numbers and booleans are, conceptually, kept with the function's execution context. Say in an interview that this is a simplification: engines typically heap-allocate strings and may share them, and a primitive stored as an object property lives on the heap with that object. The model is a mental aid, not a specification.
Garbage collection and reachability
The heap is not infinite. A background garbage collector reclaims memory using one idea, reachability:
- Roots are reachable by definition: globals, plus parameters and locals of functions currently on the stack.
- Reachable values are anything you can reach by following references from a root.
Whatever cannot be reached from any root is garbage. The collector does not care whether you still intend to use an object; if a reachable reference exists, it stays. That is how leaks happen. For a tooling-focused deep dive, see hunting JavaScript memory leaks with reachability and retainers.
Two classic ways to leak memory
A timer that never stops
In this dashboard tracker, a large array is created inside a function and an interval callback reads its length every second.
function startDashboardTracker() {
const hugeDataPayload = new Array(1000000).fill("data");
// Timer is launched
setInterval(() => {
// Because hugeDataPayload is referenced here,
// it stays trapped in memory forever!
console.log("Tracker pinging... Payload length:", hugeDataPayload.length);
}, 1000);
}
startDashboardTracker();
// Even after startDashboardTracker finishes executing and leaves the Call Stack,
// hugeDataPayload CANNOT be garbage collected because the timer's closure holds a reference to it.
When startDashboardTracker returns, you might expect the array to become garbage. It does not: the browser keeps the active interval, the interval holds its callback, and the callback's closure holds hugeDataPayload. Keep the ID returned by setInterval() and call clearInterval() when the work is done or when a UI component unmounts; breaking that first link frees the whole chain.
A listener on a global object
This one is subtler because nothing appears to be running. A search widget builds a big index and registers a resize handler on window.
function setupSearchWidget() {
// Heavy data tied to the widget scope
const heavySearchIndex = new Array(1000000).fill("search_index_item");
function handleResize() {
console.log("Window resized. Index size:", heavySearchIndex.length);
}
// Listener attached to the global 'window' object (a Root)
window.addEventListener("resize", handleResize);
}
setupSearchWidget();
// Later, user navigates away and the Search Widget DOM element is deleted.
// BUT 'window' still holds a reference to 'handleResize',
// which holds a reference to 'heavySearchIndex'.
When the user navigates away and the widget's DOM is removed, window, a root, still lists handleResize, which closes over heavySearchIndex. Call removeEventListener when the widget is torn down, and pass the same function reference you registered; a fresh arrow function is a different object and removes nothing.
Event bubbling and delegation
React and Angular hide much of this bookkeeping. One reason is event delegation: instead of a listener on every node, React listens at the root container and dispatches events to your handlers as they bubble up, and its synthetic event system attaches and detaches handlers as components mount and unmount.
Delegation relies on bubbling: a click is dispatched to the target element and then travels up through each ancestor. This page nests three coloured boxes, each with a click listener.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Event Bubbling Example</title>
<style>
#grandparent {
background-color: lightcoral;
padding: 20px;
}
#parent {
background-color: lightblue;
padding: 20px;
}
#child {
background-color: lightgreen;
padding: 20px;
}
</style>
</head>
<body>
<div id="grandparent">
<h1>Grandparent - only one event listener</h1>
<div id="parent">
Parent
<div id="child">
Child
</div>
</div>
</div>
<script>
document.getElementById('grandparent').addEventListener('click', function(event) {
console.log('Grandparent clicked!');
console.log('target: ', event.target.getAttribute("id"));
// if (event.target.getAttribute("id") === 'child') {
// //
// }
});
document.getElementById('parent').addEventListener('click', function(event) {
console.log('Parent clicked!');
// event.stopPropagation();
});
document.getElementById('child').addEventListener('click', function(event) {
console.log('Child clicked!');
// event.stopPropagation();
});
</script>
</body>
</html>
Clicking the innermost box logs child, parent, then grandparent. The grandparent reads event.target to learn which element was really clicked, and the commented if hints at how that single listener could branch on the ID so the children need none. That is delegation in miniature: one listener high in the tree covers every descendant, including ones added later, at a fraction of the memory.
The commented event.stopPropagation() calls show the opposite control: enabled in the child, they stop the event before it reaches the ancestors (see the stopPropagation docs). Use it sparingly, since it also blinds delegated handlers higher up, including a framework's root listener. Experimenting with this file directly is worthwhile; plain addEventListener should feel familiar before framework abstractions do.
Functional programming in brief
Functional programming (FP) is often pictured as an assembly line of isolated machines: each takes input, produces output and touches nothing else. Where object-oriented code tends to mutate object state, FP avoids mutation and rests on five principles:
- Pure functions: output depends only on input, with no external side effects.
- Immutability: transformations return new values rather than changing old ones.
- First-class functions: functions are values you can store and pass around.
- Higher-order functions: functions that take or return functions, enabling composition (see this overview of higher-order functions).
- Declarative style: describe the result you want, for example with
mapandfilter, instead of managing counters and state.
Classes and prototypes
Since ES2022, classes support real private fields with the # prefix. Here #balance is accessible only inside the class body.
// 1. PURE JAVASCRIPT (Native ES2022+)
class BankAccount {
#balance = 0; // Native JS private field (using #)
constructor(owner, initialBalance) {
this.owner = owner;
this.#balance = initialBalance;
}
deposit(amount) {
this.#balance += amount;
}
}
const account = new BankAccount("Alex", 1000);
// account.#balance; ❌ Uncaught SyntaxError (Hard runtime privacy)
Reading account.#balance from outside is a SyntaxError, so privacy is enforced by the language, not by convention.
Underneath, JavaScript still uses prototypal inheritance: objects delegate property lookups to a prototype. Before ES6, class-like structures were built by hand with constructor functions, as in this version of the account with a savings subclass.
// 1. BASE CONSTRUCTOR FUNCTION (Parent Class)
function BankAccount(owner, initialBalance) {
this.owner = owner; // Public property
// PRIVATE VARIABLE (Data Privacy via Closure)
// Lives in function scope, completely hidden from the outside world!
var balance = initialBalance;
// Public method privileged to access the private 'balance' variable
this.deposit = function (amount) {
balance += amount;
this._logTransaction("Deposited quot; + amount + ". New Balance: quot; + balance);
};
}
// PROTECTED / SHARED METHODS (Prototypal Inheritance)
// Attached to the prototype so methods aren't re-created for every single instance
BankAccount.prototype._logTransaction = function (action) {
console.log("[" + this.owner + "]: " + action);
};
// 2. DERIVED CONSTRUCTOR FUNCTION (Child Class)
function SavingsAccount(owner, initialBalance, interestRate) {
// Call Parent Constructor with 'this' context (Super Call)
BankAccount.call(this, owner, initialBalance);
this.interestRate = interestRate;
}
// Inherit parent prototype methods on the child
SavingsAccount.prototype = Object.create(BankAccount.prototype);
SavingsAccount.prototype.constructor = SavingsAccount; // Reset constructor reference
// Child Public Method
SavingsAccount.prototype.addInterest = function () {
// Calls the inherited prototype method
this._logTransaction("Applied " + this.interestRate + "% interest");
};
// 3. USAGE
var mySavings = new SavingsAccount("Alex", 1000, 5);
mySavings.deposit(200); // -> [Alex]: Deposited $200. New Balance: $1200
mySavings.addInterest(); // -> [Alex]: Applied 5% interest
Notice that balance is private through a closure: it lives in the constructor's scope and only deposit can see it. _logTransaction sits on the prototype so all instances share one function, and its underscore means "protected" by convention only. BankAccount.call(this, ...) is the manual super(), and Object.create links the prototypes before the overwritten constructor property is restored.
Modern front-end code favours composition over inheritance anyway. If you want stricter OOP checks such as access modifiers and interfaces, TypeScript is the better fit; this longer walkthrough goes further.
Closures
There is much more to explore, from Canvas and other browser APIs to modules, iterators and generators, but closures appear in nearly every interview and you have already met them in the leak examples. A closure is a function paired with the lexical environment it was created in. Because the inner function keeps a reference to that environment, it can still read and update the outer function's variables long after the outer function has returned.
function createCounter() {
let count = 0; // Outer variable
return function() {
count++; // Inner function accesses 'count'
return count;
};
}
const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3
count persists between calls because the returned function keeps its environment reachable, which ties straight back to the garbage collection rules. Each call to createCounter yields an independent counter.
A practical closure: debounce
A realistic use is debouncing: run a function only once calls have stopped for a given delay, as in search-as-you-type.
function debounce(func, delay = 300) {
let timer; // 1. The closure variable that holds the timeout reference
return function (...args) {
// 2. If the function is called again before the delay finishes,
// cancel the previous scheduled run
clearTimeout(timer);
// 3. Schedule a new run after the delay
timer = setTimeout(() => {
func.apply(this, args);
}, delay);
};
}
// --- Usage Example ---
function handleSearch(query) {
console.log(`Searching API for: ${query}`);
}
// Wrap our search function in the debounce function
const debouncedSearch = debounce(handleSearch, 500);
// Simulating fast typing into an input field:
debouncedSearch("a"); // Timer starts...
debouncedSearch("ap"); // Cancels "a", resets timer...
debouncedSearch("app"); // Cancels "ap", resets timer...
debouncedSearch("apple");// Cancels "app", resets timer...
// Only "Searching API for: apple" will log to the console
// 500ms after the last keypress!
The closed-over timer is the state. Each call clears the pending timeout and schedules a new one, so only the last call in a burst fires: typing "a", "ap", "app", "apple" quickly yields one search for "apple" 500 ms after the last keystroke. func.apply(this, args) forwards both arguments and calling context. For cancellation and edge cases, see writing a debounce utility from scratch.
Key takeaways
- One call stack runs your code; browser APIs do the waiting and return results through queues.
- All pending microtasks run before the next task, such as a timer callback.
- The collector keeps whatever is reachable from a root, needed or not.
- Uncleared intervals and listeners on globals are classic leaks; clean up with the original references.
- Bubbling enables delegation, which is how frameworks handle events cheaply.
- Classes are syntax over prototypes, and closures explain private state, debounce and many leaks.