ToolPopToolPop
JavaScript · Lesson 17 of 18

Debounce, throttle, memoization, and avoiding memory leaks

8 min readUpdated 24 Jun 2026

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:

Diagram
rendering diagram...
js
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:

  1. Each call clears the previous timer.
  2. A fresh timer is started for wait ms.
  3. Only the last call within the silence window actually fires fn.
  4. apply(this, args) preserves the caller's this and arguments.

Trap: returning an arrow function from debounce breaks this binding. Use a regular function so this is 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.

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

js
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:

  1. A Map lives in the closure, one per wrapped function.
  2. JSON.stringify(args) is the cheap key. Works for primitives and plain objects.
  3. Cache hit returns instantly. Cache miss runs fn, stores, returns.

When NOT to memoize:

  • The function is already cheap (string concatenation, addition). The Map lookup costs more.
  • The function is impure (reads from network, current time, randomness). You will cache the wrong answer.
  • Arguments are huge objects. JSON.stringify becomes the bottleneck.
  • The cache is unbounded. You just built a memory leak.

Trap: JSON.stringify does not handle undefined, functions, circular refs, or key order in objects. For real code, use a structural key or an LRU like lru-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.

Diagram
rendering diagram...

1. Forgotten timers.

js
const id = setInterval(() => poll(), 1000);
// component unmounts, nobody calls clearInterval(id)
// poll() runs forever, holds onto everything it closes over

Fix: always pair setInterval with clearInterval in cleanup. In React, return it from useEffect.

2. Detached DOM nodes.

js
const cached = document.getElementById("modal");
document.body.removeChild(cached);
// cached still references the node, plus all its children

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

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

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

js
const meta = new WeakMap();
let user = { id: 7 };
meta.set(user, { lastSeen: Date.now() });
user = null; // user object and its meta entry are now collectable

Use 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

0/3 answered
  1. 1.Which pattern fires the function exactly once after a burst of events?
  2. 2.Which is the right pattern for a scroll handler that should not run more than 60 times per second?
  3. 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?
A.Debounce waits until the user has stopped firing the event for N ms, then runs once (good for search-box typing). Throttle runs at most once per N ms regardless of how often the event fires (good for scroll handlers).
Q.What is a common memory leak in JS?
A.Forgotten setInterval / setTimeout that holds closures alive, listeners not removed on cleanup, detached DOM nodes still referenced by JS variables, and closures over very large arrays that the inner function does not need.
Chai0/2 done

Watching quietly. Tap me if you want a tip.

JS Playground
console
// hit run to see output

Try this (0 of 2 done)

  1. 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. 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');