ESC

Type to search the knowledge base.

Capture, Bubble, once, and passive

Event propagation phases, addEventListener options — capture, once, passive, and signal — and when each fixes a real bug.

intermediate3 min read
  • 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

  1. Capture — window → target
  2. Target — listeners on the actual event target
  3. 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.”

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