Debounce, throttle, memoization, and avoiding memory leaks
These are the patterns a senior frontend engineer reaches for without thinking. They also happen to be the most common live-coding questions in a Razorpay or Swiggy interview. Learn the shapes, type them from muscle memory.
Debounce: wait until the user stops
Debounce delays a function until the user has stopped triggering it for wait ms. The classic example is the search box on IRCTC. You do not want to fire a request on every keystroke. You want one request, after the user pauses.
Mental model:
function debounce(fn, wait = 300) {
let timer;
return function debounced(...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), wait);
};
}
const search = debounce(q => fetch(`/api/search?q=${q}`), 400);
input.addEventListener("input", e => search(e.target.value));Step-by-step execution:
- Each call clears the previous timer.
- A fresh timer is started for
waitms. - Only the last call within the silence window actually fires
fn. apply(this, args)preserves the caller'sthisand arguments.
Trap: returning an arrow function from
debouncebreaksthisbinding. Use a regular function sothisis the one from the call site.
Throttle: at most once per N ms
Throttle is the cap. No matter how many times you call it, the inner function runs at most once per wait ms. Used for scroll, mousemove, window resize.
function throttle(fn, wait = 100) {
let last = 0;
let timer;
return function (...args) {
const now = Date.now();
const remaining = wait - (now - last);
if (remaining <= 0) {
clearTimeout(timer);
timer = null;
last = now;
fn.apply(this, args);
} else if (!timer) {
timer = setTimeout(() => {
last = Date.now();
timer = null;
fn.apply(this, args);
}, remaining);
}
};
}This is the leading + trailing variant. First call fires immediately, then at most one trailing call per window. Plain timestamp throttle is shorter but drops the final event, which is usually wrong for "save scroll position".
- Debounce: "stop bothering me until they are done." Search, autosave, resize-end.
- Throttle: "I will listen, but only every 100ms." Scroll, drag, analytics ping.
Memoization: cache the expensive bit
Memoize wraps a pure function so repeated calls with the same arguments return the cached value. Useful for recursive math, heavy formatters, parsing.
function memoize(fn) {
const cache = new Map();
return function (...args) {
const key = JSON.stringify(args);
if (cache.has(key)) return cache.get(key);
const result = fn.apply(this, args);
cache.set(key, result);
return result;
};
}
const slowFib = n => n < 2 ? n : slowFib(n - 1) + slowFib(n - 2);
const fib = memoize(slowFib); // first call O(2^n), repeats O(1)Step-by-step:
- A
Maplives in the closure, one per wrapped function. JSON.stringify(args)is the cheap key. Works for primitives and plain objects.- Cache hit returns instantly. Cache miss runs
fn, stores, returns.
When NOT to memoize:
- The function is already cheap (string concatenation, addition). The
Maplookup costs more. - The function is impure (reads from network, current time, randomness). You will cache the wrong answer.
- Arguments are huge objects.
JSON.stringifybecomes the bottleneck. - The cache is unbounded. You just built a memory leak.
Trap:
JSON.stringifydoes not handleundefined, functions, circular refs, or key order in objects. For real code, use a structural key or an LRU likelru-cache.
The four classic memory leaks
A leak is memory the GC cannot reclaim because something still holds a reference. The app does not crash. It slowly turns to mud.
1. Forgotten timers.
const id = setInterval(() => poll(), 1000);
// component unmounts, nobody calls clearInterval(id)
// poll() runs forever, holds onto everything it closes overFix: always pair setInterval with clearInterval in cleanup. In React, return it from useEffect.
2. Detached DOM nodes.
const cached = document.getElementById("modal");
document.body.removeChild(cached);
// cached still references the node, plus all its childrenThe node is out of the tree but the JS variable keeps the whole subtree alive. Null the reference: cached = null.
3. Closures holding huge data.
function makeHandler() {
const bigData = new Array(1e6).fill("x"); // 1M entries
return () => console.log("clicked"); // closes over bigData
}
button.addEventListener("click", makeHandler());The handler does not use bigData, but the closure pins it in memory. Move heavy work outside, or do not capture what you do not need.
4. Listeners never removed.
window.addEventListener("resize", onResize);
// SPA route change. Component gone. Listener still wired to window.Always remove what you add. In React, return the removal from useEffect.
WeakMap and WeakRef: soft references
Map keeps its keys alive forever. WeakMap does not. If the key object is garbage-collected elsewhere, the entry vanishes.
const meta = new WeakMap();
let user = { id: 7 };
meta.set(user, { lastSeen: Date.now() });
user = null; // user object and its meta entry are now collectableUse WeakMap to attach private data to DOM nodes or domain objects without leaking them. WeakRef (ES2021) holds a reference that does not prevent GC, useful for caches that should not pin large objects.
Performance and memory
- Debounce
- Run fn only after a quiet period of wait ms. Last call wins.
- Throttle
- Run fn at most once per wait ms. Caps frequency.
- Memoize
- Cache results of a pure fn keyed by its arguments.
- Closure leak
- A returned function keeps large captured variables alive.
- WeakMap
- Map whose keys do not prevent garbage collection.
- Detached node
- DOM node removed from the tree but still referenced by JS.
Senior rule: memory leaks are silent. They look like "the app gets slower over time." Always remove listeners and clear timers in cleanup. The bug you do not find in dev is the bug your users live with.
Quick quiz, prove you got it
- 1.Which pattern fires the function exactly once after a burst of events?
- 2.Which is the right pattern for a scroll handler that should not run more than 60 times per second?
- 3.When should you NOT memoize a function?
Interview insights
When asked "what causes memory leaks in JS?"
- Forgotten setInterval / setTimeout
- Detached DOM nodes still referenced by JS
- Listeners that were not removed
- Closures capturing large data the inner function does not use
Tricky questions they will ask
Q.Why is throttle implementation often wrong on the trailing edge?›
A.A naive throttle fires immediately and then ignores everything in the window. The last call within the window gets dropped. A correct implementation calls once at the end of the window if there were further attempts.
Q.Can a closure cause a memory leak even if the function eventually goes out of scope?›
A.Yes, if you stored a reference to that closure somewhere long-lived (a global Map, a DOM event listener, a setInterval). The captured data stays alive as long as that long-lived reference does.
Free tools you can use while you learn
Common questions
Q.What is the difference between debounce and throttle?›
Q.What is a common memory leak in JS?›
Watching quietly. Tap me if you want a tip.
Try this (0 of 2 done)
- 1
Write a memoize function that caches results of a pure function. Use it on a slow square function.
show answer
function memoize(fn) { const cache = new Map(); return (x) => { if (cache.has(x)) return cache.get(x); const r = fn(x); cache.set(x, r); return r; }; } const square = memoize(n => n * n); console.log(square(7)); console.log(square(7)); - 2
Write a throttle function that only calls fn at most once per N ms. Log calls 1, 2, 3 quickly to test.
show answer
function throttle(fn, limit) { let waiting = false; return (...args) => { if (!waiting) { fn(...args); waiting = true; setTimeout(() => waiting = false, limit); } }; } const log = throttle((m) => console.log(m), 100); log('1'); log('2'); log('3');