Step 07

Taming the storm

Most events are polite: a click here, a submit there. A few are a firehose. scroll and pointermove can fire on every frame; resize streams continuously while a window edge is dragged; input fires per keystroke. Your handler runs every single time — and if it does real work, the page pays for that work sixty times a second. The fix is not a faster handler. It is running the handler less often, on purpose.

Learning Objectives

  • Identify the high-frequency events and what they cost
  • Throttle: run at most once per interval, while activity continues
  • Debounce: run once, after activity stops
  • Choose between them by asking what the handler is for
  • Mark scroll and touch listeners passive

Two small functions

Both tools are wrappers: give them your handler and a wait time, get back a rate-limited version to hand to addEventListener. These are the demo's actual implementations — a dozen lines, no library:

function throttle(fn, wait) { let last = 0, t; return (...args) => { const now = Date.now(); if (now - last >= wait) { last = now; fn.apply(null, args); } else { clearTimeout(t); t = setTimeout(() => { last = Date.now(); fn.apply(null, args); }, wait - (now - last)); } }; } function debounce(fn, wait) { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn.apply(null, a), wait); }; }

Throttle lets the handler through at most once per wait milliseconds — a steady drumbeat while events keep arriving. Debounce resets a timer on every event and fires only when the events stop for wait milliseconds — silence, then one call.

Choose by purpose, not preference. Ask what the handler is for. Tracking something continuously — a scroll position indicator, an element following the pointer — wants throttle: regular updates, just fewer of them. Reacting to a finished action — a search query, a resized layout — wants debounce: only the final state matters.

Watch the counters diverge

The demo wires the same events to normal, throttled, and debounced handlers side by side. Drag the scroll box and the normal counter races ahead while throttle ticks steadily and debounce waits for you to stop. Then try the two search inputs — the canonical case. Every keystroke in the plain input is an “API call”; the debounced one waits 500 ms of quiet and makes one:

let c1 = 0, c2 = 0; const fakeApi = () => {}; document.getElementById('s1').addEventListener('input', () => { fakeApi(); document.getElementById('c1').textContent = ++c1; }); document.getElementById('s2').addEventListener('input', debounce(() => { fakeApi(); document.getElementById('c2').textContent = ++c2; }, 500));

The resize section watches the window, so its counters only move when the window actually resizes — open the demo standalone and drag an edge to see it.

The browser's half: passive listeners

Rate-limiting fixes how often your code runs. There is a second cost you cannot fix with less code: for scroll and touch events, the browser must wait for your handler before moving the page, because you might call preventDefault(). Declaring the listener passive is a promise you won't:

area.addEventListener('scroll', onScroll, { passive: true });

With that promise made, scrolling never blocks on your JavaScript. The two techniques compose: passive tells the browser it may proceed without you; throttle and debounce decide how often you bother showing up at all.

Next Steps

That completes the event model: binding, the event object, flow, delegation, forms, your own events, and the firehose. Everything from here on uses it. In The DOM series, events are how every demo is driven — and the tree is what they drive:

  • The parse tree the browser builds from your markup
  • Selecting, walking, modifying, creating, and destroying nodes
  • Geometry, observers, and the libraries beyond the raw DOM