The topics that separate someone who writes JavaScript from someone who debugs it. Memory leaks, deep clone, deep equal, event delegation, and the output-prediction puzzles that open screening rounds.
Garbage collection
In simple words: JavaScript frees memory for you, but only for values nothing can reach any more. Understanding "reachable" is the whole topic.
The engine starts from a set of roots — the global object, the current call stack, and anything they hold. It follows every reference outward. Whatever it reaches is kept. Everything else is swept away. This is called mark and sweep.
This is why circular references are not a leak in JavaScript. Two objects pointing at each other are collected together as long as no root reaches either. Old reference-counting systems could not do that.
Modern engines add generational collection: most objects die young, so V8 keeps a small "young" space it scans often and cheaply, and promotes survivors to an "old" space it scans rarely.
The four real memory leaks
In simple words: a leak in JavaScript means something still points at data you thought was gone. There are four common causes.
1. Accidental globals
function process() {
results = []; // no declaration -- becomes a global, never collected
}Strict mode turns this into an error. This is one of the main reasons strict mode exists.
2. Forgotten timers
// Leaks: the interval holds `hugeData` for the life of the page
const hugeData = await loadEverything();
setInterval(() => sync(hugeData), 1000);
// Fixed
const id = setInterval(() => sync(hugeData), 1000);
// ...later
clearInterval(id);3. Detached DOM nodes
const cache = {};
function show(id) {
const element = document.getElementById(id);
cache[id] = element; // holds the node
element.remove(); // removed from the page...
// ...but cache still points at it, so the node AND its whole subtree stay in memory
}The node is gone from the page but not from memory. A WeakMap fixes this — when the node becomes otherwise unreachable, the entry goes with it.
4. Listeners that outlive their element
function attach(element, data) {
const handler = () => use(data); // closure holds `data`
element.addEventListener("click", handler);
return () => element.removeEventListener("click", handler); // return the cleanup
}Note the pattern: keep the exact function reference, because removeEventListener matches by identity. A fresh arrow function or a new bind call never matches.
Deep clone
In simple words: a copy where nothing is shared with the original, at any depth.
// The modern answer, and the right one most of the time
const copy = structuredClone(original);It handles nested objects, arrays, Date, Map, Set, RegExp, typed arrays and circular references. It throws on functions, DOM nodes and symbols.
Interviewers still want it by hand:
function deepClone(value, seen = new WeakMap()) {
// primitives and functions are returned as-is
if (value === null || typeof value !== "object") return value;
// a circular reference we have already cloned
if (seen.has(value)) return seen.get(value);
if (value instanceof Date) return new Date(value.getTime());
if (value instanceof RegExp) return new RegExp(value.source, value.flags);
if (value instanceof Map) {
const out = new Map();
seen.set(value, out); // register BEFORE recursing
value.forEach((v, k) => out.set(deepClone(k, seen), deepClone(v, seen)));
return out;
}
if (value instanceof Set) {
const out = new Set();
seen.set(value, out);
value.forEach((v) => out.add(deepClone(v, seen)));
return out;
}
const out = Array.isArray(value) ? [] : Object.create(Object.getPrototypeOf(value));
seen.set(value, out);
for (const key of Reflect.ownKeys(value)) { // includes symbol keys
out[key] = deepClone(value[key], seen);
}
return out;
}The two details that matter: register the clone in the WeakMap before recursing — otherwise a cycle recurses forever — and use Reflect.ownKeys so symbol-keyed properties survive.
Deep equal
In simple words: are these two values the same all the way down? Harder than it looks, because of NaN, -0, Dates, Maps and cycles.
function deepEqual(a, b, seen = new WeakMap()) {
// Object.is handles NaN === NaN as true and +0 === -0 as false
if (Object.is(a, b)) return true;
if (a === null || b === null) return false;
if (typeof a !== "object" || typeof b !== "object") return false;
if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) return false;
// already comparing this pair further up the stack -- assume equal
if (seen.get(a) === b) return true;
seen.set(a, b);
if (a instanceof Date) return a.getTime() === b.getTime();
if (a instanceof RegExp) return a.source === b.source && a.flags === b.flags;
if (a instanceof Map) {
if (a.size !== b.size) return false;
for (const [key, value] of a) {
if (!b.has(key) || !deepEqual(value, b.get(key), seen)) return false;
}
return true;
}
if (a instanceof Set) {
if (a.size !== b.size) return false;
for (const value of a) if (!b.has(value)) return false;
return true;
}
const keysA = Reflect.ownKeys(a);
const keysB = Reflect.ownKeys(b);
if (keysA.length !== keysB.length) return false;
return keysA.every(
(key) =>
Object.prototype.hasOwnProperty.call(b, key) && deepEqual(a[key], b[key], seen)
);
}Events: bubbling, capturing and delegation
In simple words: a click on a button does not just fire on the button. The event travels down from the root to the target, then back up again.
// Bubble phase (default) -- runs on the way back up
element.addEventListener("click", handler);
// Capture phase -- runs on the way down, before the target
element.addEventListener("click", handler, { capture: true });
// Runs once, then removes itself
element.addEventListener("click", handler, { once: true });
// Promises the browser you will not call preventDefault -- improves scroll performance
element.addEventListener("touchstart", handler, { passive: true });The three stop methods
| Method | What it does |
|---|---|
preventDefault() | cancels the browser's default action (following a link, submitting a form). Does not stop propagation. |
stopPropagation() | stops the event travelling further up or down. Other listeners on the same element still run. |
stopImmediatePropagation() | stops propagation and skips remaining listeners on the same element. |
Delegation
In simple words: instead of a listener on every item, put one on the parent and work out which child was clicked. Because events bubble, the parent sees them all.
// One listener for the whole list, however many items -- including future ones
list.addEventListener("click", (event) => {
const item = event.target.closest("[data-id]");
if (!item || !list.contains(item)) return;
handleClick(item.dataset.id);
});Three reasons this is the right default: it uses one listener instead of a thousand, it works for elements added later, and there is nothing to clean up when items are removed.
Output-prediction puzzles
In simple words: these combine hoisting, closures, this and the event loop into one trap. Screening rounds love them. Work through each slowly.
// 1
var x = 1;
function f() {
console.log(x);
var x = 2;
}
f();
// undefined -- the inner `var x` is hoisted and shadows the outer one// 2
const obj = {
name: "outer",
regular() { return this.name; },
arrow: () => this?.name,
};
obj.regular(); // "outer"
obj.arrow(); // undefined -- arrow took `this` from the module scope// 3
console.log("start");
setTimeout(() => console.log("timeout"), 0);
Promise.resolve().then(() => console.log("promise"));
console.log("end");
// start, end, promise, timeout// 4
for (var i = 0; i < 3; i++) setTimeout(() => console.log(i));
// 3 3 3 -- one shared `i`, read after the loop finished// 5
console.log([1, 2, 3] + [4, 5]);
// "1,2,34,5" -- + on two objects converts both to strings first// 6
const a = { x: 1 };
const b = { x: 1 };
console.log(a == b, JSON.stringify(a) === JSON.stringify(b));
// false true -- == compares addresses, stringify compares text// 7
async function f() { return 1; }
f().then(console.log);
console.log(2);
// 2 then 1 -- an async function always returns a promise, so `then` is a microtaskHow to answer these in a room: say the rule before the answer. "This is hoisting, so the inner var shadows — undefined." Interviewers are grading the reasoning; a correct answer with no explanation scores lower than a slightly wrong one with the right rule named.
Regex
In simple words: you do not need to be fluent. You need to read one, know the common pieces, and know the traps.
| Piece | Meaning |
|---|---|
\d \w \s | digit, word character, whitespace (capitals negate) |
+ * ? | one or more, zero or more, zero or one |
{2,5} | between 2 and 5 |
^ $ | start, end of string (or line with m) |
[abc] [^abc] | any of, none of |
(...) | capture group |
(?:...) | group without capturing |
(?<name>...) | named capture |
(?=...) (?!...) | lookahead: followed by, not followed by |
(?<=...) (?<!...) | lookbehind |
| | or |
Flags: g all matches, i ignore case, m multiline, s dot matches newlines, u unicode.
// Greedy vs lazy -- the most common regex bug
"<a><b>".match(/<.+>/)[0]; // "<a><b>" -- greedy, takes as much as possible
"<a><b>".match(/<.+?>/)[0]; // "<a>" -- lazy, takes as little as possible
// Named groups make results readable
const m = "2026-09-01".match(/(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/);
m.groups.year; // "2026"
// All matches with their positions
[..."a1b2".matchAll(/([a-z])(\d)/g)].map((m) => m[1] + m[2]);Dates and time zones
In simple words: the built-in Date is genuinely bad, and the mistakes it causes are subtle enough to reach production.
new Date("2026-09-01"); // parsed as UTC midnight
new Date("2026-09-01T00:00:00"); // parsed as LOCAL midnight -- different instant
new Date(2026, 8, 1); // September -- months are zero-based
date.getMonth(); // 0 to 11, forever a source of off-by-one bugsThe rules that keep you out of trouble:
- Store UTC. In Postgres that is
timestamptz, and send ISO 8601 strings over the wire. - Convert at the edges. Convert to the user's zone only when displaying, and back to UTC on input.
- A date is not an instant. A birthday is a calendar date with no time zone. A meeting start is an instant. Storing a birthday as a timestamp shifts it a day for some users.
- Never do date maths with milliseconds. Adding 24 hours across a daylight-saving boundary gives the wrong local time.
// Formatting for a user, correctly, with no library
new Intl.DateTimeFormat("en-IN", {
dateStyle: "medium",
timeStyle: "short",
timeZone: "Asia/Kolkata",
}).format(date);Libraries: date-fns for tree-shakeable functions, Day.js when you want a small Moment-like API, Luxon when time zones are central. Moment is in maintenance mode — do not start new work with it.
Temporal is the coming replacement for Date, with separate types for instants, calendar dates, times and durations, and immutability throughout. Not everywhere yet, but worth naming — it shows you know why Date is a problem.
Intl
In simple words: the browser already knows how to format numbers, currency and dates for every locale. Most projects reinvent it badly.
new Intl.NumberFormat("en-IN", {
style: "currency", currency: "INR",
}).format(1234567.891); // "₹12,34,567.89" -- lakh grouping, correct
new Intl.NumberFormat("en", {
notation: "compact",
}).format(1234567); // "1.2M"
new Intl.RelativeTimeFormat("en", { numeric: "auto" }).format(-1, "day"); // "yesterday"
new Intl.ListFormat("en", { type: "conjunction" }).format(["a", "b", "c"]); // "a, b, and c"
["z", "ä", "a"].sort(new Intl.Collator("de").compare); // correct German orderThe Indian grouping example is the one to remember. toLocaleString with a naive implementation gives 1,234,567; the correct Indian format is 12,34,567. Intl gets it right for free.
Ten questions
- 01How does JavaScript decide what to garbage collect, and why are circular references not a leak?
It marks everything reachable from the roots — globals and the call stack — and sweeps the rest. Reachability, not reference counting, decides.
Two objects referencing each other are unreachable if nothing else points at them, so both are collected. Reference counting could not handle that; mark and sweep can.
- 02Name the four common memory leaks in a browser app.
Accidental globals from undeclared assignment. Timers that are never cleared. Detached DOM nodes still referenced by a cache or closure. Event listeners never removed.
All four are the same thing underneath: something still holds a reference you forgot about.
- 03Why does
removeEventListenersometimes fail to remove anything?It matches by function identity. Passing a fresh arrow function, or calling
.bind()again, creates a different function object that never matches the one added.Store the handler reference once and use that same reference for both add and remove.
- 04In a hand-written deep clone, why register the copy in the WeakMap before recursing?
Because of circular references. If an object contains itself, recursing before registering means you clone it again, and again, until the stack overflows.
Registering first means the recursion finds the in-progress clone and returns it, which correctly reproduces the cycle.
- 05Why use
Object.israther than===at the start of a deep equal?Object.istreatsNaNas equal to itself, which===does not, and treats+0and-0as different, which===does not.Both are cases a correct deep equal should handle, and both are what an interviewer is checking for.
- 06What is the difference between
preventDefault,stopPropagationandstopImmediatePropagation?preventDefaultcancels the browser's default action but the event keeps travelling.stopPropagationstops the travel but other listeners on the same element still run.stopImmediatePropagationstops the travel and skips the remaining listeners on that element too. - 07What is event delegation and why prefer it?
Attaching one listener to a parent and using
event.target.closest()to find which child was involved, relying on bubbling.One listener instead of many, it automatically covers elements added later, and there is nothing to clean up when children are removed.
- 08What does this print, and why?
var x = 1; function f() { console.log(x); var x = 2; }undefined. The innervar xis hoisted to the top of the function, so insidefthe namexrefers to the local variable, which is still uninitialised at that point.The outer
xis completely shadowed — it is never consulted. - 09Why can reusing a regex with the
gflag give alternating results?A global regex keeps a
lastIndexbetween calls. The nextteststarts from there, misses, resets to zero, and matches again on the call after — so you get true, false, true, false on identical input.Create the regex fresh each time, reset
lastIndex, or dropgwhen you only need a boolean. - 10How should dates and time zones be handled in a full stack app?
Store UTC in the database —
timestamptzin Postgres — and transmit ISO 8601. Convert to the user's zone only for display, and back to UTC on input.Distinguish a calendar date, like a birthday, from an instant, like a meeting start; storing a birthday as a timestamp shifts it a day for some users. And never add 24 hours in milliseconds to mean "tomorrow", because daylight saving breaks it.
Six exercises
- 01
Create and then find a leak
Build a page that adds DOM nodes to a plain object cache and removes them from the page. Take two heap snapshots in Chrome and find the detached nodes. Then switch the cache to a WeakMap and prove the nodes are collected.
- 02
deepClone with cycles
Handle nested objects, arrays, Date, Map, Set, RegExp and symbol keys, and survive an object that contains itself. Compare your output with
structuredCloneon the same input and explain any difference. - 03
deepEqual that passes the nasty cases
Make it correct for
NaN,+0versus-0, Dates, Maps, Sets, different prototypes, different key counts and circular references. Write a test for each case before writing the function. - 04
Delegated list
Render 1,000 rows with a delete button in each. Implement it with 1,000 listeners, then with one delegated listener. Measure the listener count and memory in devtools, and confirm the delegated version works for rows added afterwards.
- 05
Do the seven puzzles cold
Write your predicted output for all seven above without running them. Then run them. For every one you got wrong, write down which rule you missed rather than just the right answer.
- 06
Format without a library
Using only
Intl, display a rupee amount with correct lakh grouping, a compact number, a relative time like "3 days ago", and a date in Asia/Kolkata. Then show what the same amount looks like formatted foren-US.