Event Handling
Date: 2026-08-16
An event travels down the tree to the target and back up again. Knowing which phase you’re listening in explains delegation, why
stopPropagationbreaks other people’s code, and why a scroll listener can make a page stutter.
What it is
Event handling is the browser dispatching an event object through the DOM tree, giving each ancestor a chance to respond on the way down and on the way back up.
CAPTURE ↓ ↑ BUBBLE
│ │
document ────┤ ├──── document
body ──────┤ ├────── body
ul ──────┤ ├──────── ul
li ────┴──── TARGET ────────┘
Three phases: capture (document → target), target, bubble (target → document). Listeners default to the bubble phase.
el.addEventListener('click', fn); // bubble (default)
el.addEventListener('click', fn, true); // capture
el.addEventListener('click', fn, { capture: true }); // same, clearerNot everything bubbles — focus, blur, load and error don’t. Their bubbling equivalents are focusin and focusout.
Delegation
Because events bubble, one listener on an ancestor can handle any number of descendants.
// ONE listener, works for items added later
document.querySelector('.product-grid').addEventListener('click', e => {
const btn = e.target.closest('[data-add-to-basket]');
if (!btn) return;
addToBasket(btn.dataset.addToBasket);
});// n listeners, and nothing works for elements added after this runs
document.querySelectorAll('[data-add-to-basket]').forEach(btn =>
btn.addEventListener('click', () => addToBasket(btn.dataset.addToBasket))
);Delegation wins on three counts: constant memory regardless of item count, it works for dynamically-inserted elements, and there’s nothing to tear down when they’re removed. closest() is what makes it robust — it handles clicks landing on an icon or text node inside the button.
This is also how most tag managers implement click tracking, which is why they catch elements that didn’t exist at page load — see Tag Managers.
target vs currentTarget
The distinction that causes the most delegation bugs:
| Property | Is |
|---|---|
e.target | The deepest element the event actually landed on — possibly a <span> inside your button |
e.currentTarget | The element whose listener is running |
Inside a delegated handler, currentTarget is the container. target is whatever was clicked. Use closest() from target to find the thing you meant.
Stopping things
e.preventDefault(); // cancel the browser's default action
e.stopPropagation(); // stop the event travelling furtherThey’re unrelated and routinely confused. preventDefault stops a link navigating or a form submitting; propagation continues. stopPropagation stops other listeners further up from ever running — including analytics, and including the framework’s.
stopPropagation is antisocial. It silently breaks code that has no relationship to yours, and the failure appears as “tracking doesn’t fire on this button” weeks later. Prefer a check in your own handler over stopping the event for everyone.
Passive listeners
Scroll and touch handlers are special. The browser must know whether you’ll call preventDefault() before it can scroll — otherwise it would have to run your handler first and risk you cancelling the scroll after it had already happened.
el.addEventListener('touchstart', fn); // browser must wait
el.addEventListener('touchstart', fn, { passive: true }); // browser scrolls nowpassive: true promises you won’t cancel, letting the browser scroll immediately on the compositor thread. Without it, a slow handler delays scrolling directly. Modern browsers default touchstart and touchmove to passive on the document, but not on individual elements [CHECK: current default-passive scope per engine].
Calling preventDefault() inside a passive listener does nothing and logs a warning.
Practical
- Delegate by default on lists, grids and anything dynamic
- Throttle or debounce high-frequency events —
scroll,resize,mousemovefire far faster than you need, and each handler runs on the main thread — Long Tasks and Blocking - Prefer
IntersectionObserverover scroll listeners for “is it visible” questions. It runs off the main thread and is both simpler and faster - Remove listeners you added to elements you remove, or the closure keeps the node alive — Memory and Long Sessions
{ once: true }for one-shot handlers, so cleanup is automatic- Keep handlers short. Whatever runs here runs on the thread that also renders, and the delay is what Interaction to Next Paint measures