JavaScript · 9 topics

Memory, gotchas and puzzles

The topics that separate someone who writes JavaScript from someone who debugs it, including the output-prediction puzzles screening rounds open with.

Published
Reading time
24 min read
Topics covered
9 topics

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.

ROOTSglobals, stackABreachable — keptCDunreachable — collectedeven though they point at each otherMARK FROM THE ROOTS, THEN SWEEP THE REST
Reachability, not reference counting, decides what survives. The two objects on the right reference each other but nothing reaches them, so both are collected.

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.

1. CAPTURE — DOWN3. BUBBLE — UPdocumentbodyulbutton — 2. TARGETaddEventListener(type, fn) listens on bubble. Pass true or capture: true for the way down.
Capture travels down to the target, then bubble travels back up. A listener runs on whichever phase it registered for.
// 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

MethodWhat 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 microtask

How 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.

PieceMeaning
\d \w \sdigit, 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 bugs

The rules that keep you out of trouble:

  1. Store UTC. In Postgres that is timestamptz, and send ISO 8601 strings over the wire.
  2. Convert at the edges. Convert to the user's zone only when displaying, and back to UTC on input.
  3. 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.
  4. 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 order

The 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

  1. 01
    How does JavaScript decide what to garbage collect, and why are circular references not a leak?
  2. 02
    Name the four common memory leaks in a browser app.
  3. 03
    Why does removeEventListener sometimes fail to remove anything?
  4. 04
    In a hand-written deep clone, why register the copy in the WeakMap before recursing?
  5. 05
    Why use Object.is rather than === at the start of a deep equal?
  6. 06
    What is the difference between preventDefault, stopPropagation and stopImmediatePropagation?
  7. 07
    What is event delegation and why prefer it?
  8. 08
    What does this print, and why? var x = 1; function f() { console.log(x); var x = 2; }
  9. 09
    Why can reusing a regex with the g flag give alternating results?
  10. 10
    How should dates and time zones be handled in a full stack app?

Six exercises

  1. 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.

  2. 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 structuredClone on the same input and explain any difference.

  3. 03

    deepEqual that passes the nasty cases

    Make it correct for NaN, +0 versus -0, Dates, Maps, Sets, different prototypes, different key counts and circular references. Write a test for each case before writing the function.

  4. 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.

  5. 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.

  6. 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 for en-US.

Common questions

How does garbage collection work in JavaScript?
The engine marks everything reachable from the roots - the global object and the call stack - and sweeps away the rest. Because it is based on reachability rather than reference counting, two objects that reference each other are collected fine as long as nothing else points at them. V8 adds a generational split, scanning a small young space often and an old space rarely.
What are the most common memory leaks in a JavaScript app?
Accidental globals from undeclared assignment, timers that are never cleared, detached DOM nodes still held by a cache or closure, and event listeners that are never removed. All four are the same problem: something still holds a reference you forgot about.
Why does removeEventListener sometimes not remove the listener?
It matches by function identity. Passing a fresh arrow function, or calling bind again, creates a different function object that never matches the one that was added. Store the handler reference once and use that same reference for both adding and removing.
What is event delegation and why use it?
Attaching one listener to a parent element and using event.target.closest to work out which child was involved, relying on the fact that events bubble upward. It uses one listener instead of many, automatically covers elements added later, and leaves nothing to clean up when children are removed.
How should dates and time zones be handled in a web application?
Store UTC in the database, using timestamptz in Postgres, and transmit ISO 8601 strings. Convert to the user's zone only when displaying and back to UTC on input. Distinguish a calendar date such as a birthday from an instant such as a meeting start, and never add 24 hours in milliseconds to mean tomorrow, because daylight saving breaks it.