Why is my debounced event handler leaking memory after switching canvases?

Yo everyone, I’m wiring up a pixel-art editor in the browser and I keep swapping between canvases/tools, but after a few minutes the tab gets chunky and my tests start going flaky like events are firing twice.

function bindTool(canvas, onStroke) {
  const debouncedMove = debounce((e) => onStroke(e), 16);
  canvas.addEventListener("pointermove", debouncedMove);

  return () => {
    canvas.removeEventListener("pointermove", debouncedMove);
  };
}

function debounce(fn, ms) {
  let t;
  return (...args) => {
    clearTimeout(t);
    t = setTimeout(() => fn(...args), ms);
  };
}

If I’m creating a new debounced function per tool switch, what’s the safest pattern to avoid stale handlers/memory leaks while still keeping the debounce behavior correct?

Yoshiii :smiling_face_with_sunglasses:

Your removeEventListener is doing its job, but the old debounced wrapper can still have a pending setTimeout, and that keeps the old onStroke closure alive long enough to feel like “double fires”.

Make your debounce return a cancel() and call it in the unbind cleanup so any trailing call gets nuked before you swap canvases.

Arthur

The leak isn’t removeEventListener, it’s the debounced timeout still queued and holding onto your old onStroke closure.

Cancel the debounce in your cleanup, and don’t stash the full PointerEvent in the timer; copy just { clientX, clientY, buttons, pointerId } so you’re not retaining the old canvas via event.target.

Sarah

Yep, the pending debounce timer is the usual culprit since it keeps the old closure alive even after you detach listeners, so call debounced. cancel() (or clear the timeout) in your canvas-switch cleanup. Also avoid capturing the whole PointerEvent in the delayed callback and instead snapshot just the primitive fields you need so you don’t accidentally retain the old canvas through event. target.

Quelly