Capture, Bubble, once, and passive
Event propagation phases, addEventListener options — capture, once, passive, and signal — and when each fixes a real bug.
- javascript
- events
- capture
- passive
Most people only know the bubble phase: click a button, handler on the button runs, then parents. The DOM also has capture (root → target) and listener options that change lifetime and scroll performance. Interviews love “what is passive?” because it shows you read the performance notes, not just the tutorial.
Three phases
- Capture — window → target
- Target — listeners on the actual event target
- Bubble — target → window
const outer = document.querySelector('#outer');
const inner = document.querySelector('#inner');
outer.addEventListener('click', () => console.log('outer bubble'));
outer.addEventListener('click', () => console.log('outer capture'), true);
// or { capture: true }
inner.addEventListener('click', () => console.log('inner'));
// click inner → "outer capture" → "inner" → "outer bubble"
event.eventPhase is CAPTURING_PHASE, AT_TARGET, or BUBBLING_PHASE. stopPropagation() cuts the rest of the path; stopImmediatePropagation() also skips other listeners on the same node.
Options object (know these four)
el.addEventListener('click', handler, {
capture: false, // true → listen on way down
once: true, // auto-remove after first fire
passive: true, // promise not to preventDefault (scroll perf)
signal: controller.signal, // remove when aborted
});
once
button.addEventListener(
'click',
() => track('first_click_only'),
{ once: true },
);
Same as removing yourself manually; cleaner for one-shot UI.
passive (scroll and touch)
The browser wants to start scrolling immediately. If any touch/wheel listener might call preventDefault(), it must wait to see if you cancel. Marking passive: true tells the engine: I won’t cancel — scroll freely.
// good for analytics / parallax that never cancels scroll
window.addEventListener('touchstart', onTouch, { passive: true });
window.addEventListener('wheel', onWheel, { passive: true });
// need preventDefault (e.g. custom pull-to-refresh conflict)? cannot be passive
area.addEventListener(
'touchmove',
(e) => {
if (shouldBlock(e)) e.preventDefault();
},
{ passive: false },
);
Calling preventDefault() inside a passive listener is ignored (and may warn in the console).
signal cleanup
const c = new AbortController();
el.addEventListener('click', onClick, { signal: c.signal });
// later
c.abort(); // removes the listener
Handy in component teardown alongside AbortController for fetch.
Capture use cases
- Intercept before a child stops propagation
- Global shortcuts that must see events children might cancel
- Event delegation on the way down (rare; bubble delegation is more common)
// logging every click before anyone can stop it
document.addEventListener(
'click',
(e) => console.debug('click path', e.composedPath()),
true,
);
Removing listeners
Removal must match same function reference and same capture flag:
el.addEventListener('click', fn, true);
el.removeEventListener('click', fn); // fails — capture default false
el.removeEventListener('click', fn, true); // works
Anonymous functions can never be removed — keep a named reference or use { signal }.
Interview answer
“Events go capture, target, bubble. addEventListener takes capture/once/passive/signal. passive means I won’t preventDefault, which lets the browser scroll without waiting. once auto-removes. I remove listeners with the same capture option or an AbortSignal.”
Related
Delegation + capture together
// capture on document to see events before stopPropagation in children
document.addEventListener(
'click',
(e) => {
if (e.target.closest('[data-track]')) {
analytics.track(e.target.dataset.track);
}
},
{ capture: true, passive: true },
);
Passive makes sense for pure observation. If a design-system overlay must prevent background scroll on touch, that listener cannot be passive — document the exception. Also remember: { once: true } with capture is independent of bubble once; they are separate registrations if you add both.
Test removal in SPAs: navigating away without signal or removeEventListener leaks handlers on window/document, which is a common production memory creep.
Further reading
Related guides
- Custom EventsCustomEvent, detail payloads, bubbles and composed — decoupling components without a global event bus mess.
- Event DelegationHandle many elements with one listener on a parent — bubbling, currentTarget vs target, and when not to delegate.
- AbortControllerCancel fetch and other async work with AbortController and AbortSignal — timeouts, race conditions, and cleanup when components unmount.
- Array find, some, every, includesShort-circuiting array predicates: find, findIndex, some, every, and includes — when to use each and common interview traps.
- Array map, filter, reducemap, filter, and reduce as the core transform toolkit — immutability, chaining costs, reduce patterns, and when a plain loop is clearer.