Skip to main content

Event propagation in JavaScript and its phases

Event propagation is the path a browser takes to deliver a DOM event through the element tree: down from the root (capturing), at the target element, then back up (bubbling).

Theory

TL;DR

  • Clicking a button triggers 3 phases: capturing (root to target), target, bubbling (target to root)
  • Default addEventListener runs in the bubbling phase
  • Pass true as the third argument to register a capturing listener
  • stopPropagation() halts travel in the current direction, but handlers in the other phase may already have run
  • Use bubbling for event delegation on parents; use capturing for early interception on containers

Quick example

js
['grandparent', 'parent', 'child'].forEach(id => { const el = document.getElementById(id); // Capturing: fires top-down el.addEventListener('click', () => console.log(id + ' capture'), true); // Bubbling: fires bottom-up (default) el.addEventListener('click', () => console.log(id + ' bubble')); }); // Click #child outputs: // grandparent capture -> parent capture -> child capture // child bubble -> parent bubble -> grandparent bubble

When you click #child, capture listeners fire from grandparent down. Then bubble listeners fire from child back up. The target (#child) runs both in the same pass.

The three phases

Every click, keydown, or focus event travels the same route. The browser first builds a propagation path from window down to the target element using composedPath(), then walks that array in three passes.

Capturing runs top-down. The event visits every ancestor of the target before arriving, giving each one a chance to intercept it early. Most developers skip registering capture handlers entirely. The phase still runs regardless.

Target phase fires on the actual clicked element. Both capturing and bubbling handlers attached directly to that element run here, in the order they were registered.

Bubbling runs bottom-up. The event climbs back along the same path. This is the default, so it is where most handlers live.

Key difference

Capturing lets you intercept an event before it reaches its destination. A modal overlay can catch clicks in the capture phase and block them from hitting child buttons. Bubbling is what makes event delegation work: one listener on a parent catches clicks from every descendant because those clicks travel upward through the tree.

When to use

  • Block clicks before they reach children: capturing on an ancestor (modals, permission guards)
  • Handle dynamic lists without per-item listeners: bubbling on the parent, filter with e.target.closest()
  • React only to the exact element that was clicked: check e.target === e.currentTarget
  • Stop travel at a specific node: call e.stopPropagation() inside the handler

How browsers build and walk the propagation path

Chrome builds the full path via composedPath(), which returns an array from the target up to window. During dispatch the browser reverses that array for the capture pass, fires on the target, then walks forward for the bubble pass. event.eventPhase tells you which pass is active: 1 = capturing, 2 = at target, 3 = bubbling.

Shadow DOM changes things. Events crossing a shadow boundary get retargeted: inside the shadow root e.target is the real source element, but outside the host it becomes the host element itself. Capture listeners outside the shadow cannot pierce the boundary.

Common mistakes

Assuming stopPropagation() kills the event completely

js
child.addEventListener('click', e => { e.stopPropagation(); // stops bubbling console.log('child handler'); }); // Capture listeners on ancestors already fired before this ran

stopPropagation() only stops travel in the current direction. If a parent had a capture listener, it already ran before the child received the event.

Expecting capturing behavior without true

js
// Bubbling - runs after the child handler finishes parent.addEventListener('click', handler); // Capturing - runs before the child handler parent.addEventListener('click', handler, true);

Default is always bubbling. If you need the parent to act before the child, you need the true flag.

Trusting e.target without narrowing it in delegated handlers

js
ul.addEventListener('click', e => { // e.target might be a <button> inside <li>, not the <li> itself console.log(e.target.textContent); }); // Fix: climb to the element you actually want ul.addEventListener('click', e => { const item = e.target.closest('li'); if (!item) return; console.log(item.textContent); });

e.target is the deepest element that received the click. Use closest() to reach the container you care about.

Registering the same handler twice

js
el.addEventListener('click', handler); el.addEventListener('click', handler); // both run independently // Fix: use {once: true} or remove before re-adding el.addEventListener('click', handler, { once: true });

Two separate addEventListener calls create two separate handlers. {once: true} removes the handler automatically after the first call.

Real-world usage

  • React 18: onClick delegates all events to the root container, not the individual element. That is why e.target stays correct even inside async callbacks
  • Vue 3: @click.capture opts into the capturing phase; @click.stop calls stopPropagation() automatically
  • jQuery / Bootstrap: .on('click', selector, handler) relies on bubbling delegation for dynamically added elements
  • Any dynamic list: one listener on the container, filter targets with e.target.matches('li') or e.target.closest('li')

Follow-up questions

Q: What does event.eventPhase return and when is it useful?
A: It returns 1 during capturing, 2 when the event is on the target element, and 3 during bubbling. Reading it inside a handler lets you confirm which pass is currently active, useful when the same function handles both phases.

Q: How does stopPropagation() differ from stopImmediatePropagation()?
A: stopPropagation() prevents the event from moving to the next element in the propagation path. stopImmediatePropagation() does that and also cancels any remaining handlers on the current element registered in the same phase.

Q: Does event propagation work across iframes?
A: No. An iframe is a separate browsing context with its own window object. Events do not cross that boundary, which is by design for security reasons.

Q: What is composedPath() and when do you need it?
A: It returns the full propagation path including elements inside Shadow DOM slots. You need it in web components when you want the original click origin before retargeting happened at the shadow boundary.

Q: In a Shadow DOM tree with slots, what happens to e.target during capture from the host?
A: Capture starts at the document root. At the shadow boundary the browser retargets e.target to the host element for all external listeners. Inside the shadow root e.target still shows the actual clicked element. Calling e.composedPath() exposes the unmodified full path including all shadow slots.

Examples

Basic: three-level propagation trace

html
<div id="grandparent"> <div id="parent"> <button id="child">Click me</button> </div> </div> <script> ['grandparent', 'parent', 'child'].forEach(id => { const el = document.getElementById(id); el.addEventListener('click', () => console.log(id + ' capture'), true); el.addEventListener('click', () => console.log(id + ' bubble')); }); // Output when button is clicked: // grandparent capture // parent capture // child capture // child bubble // parent bubble // grandparent bubble </script>

The console trace shows the full travel path. Capture goes top-down, bubble goes bottom-up. The target element (#child) participates in both directions within the same dispatch cycle.

Intermediate: event delegation on a dynamic list (React 18)

jsx
function TodoList({ todos }) { const handleClick = (e) => { const btn = e.target.closest('[data-delete]'); if (!btn) return; console.log('Delete todo id:', btn.dataset.id); // 'Delete todo id: 42' }; return ( <ul onClick={handleClick}> {todos.map(todo => ( <li key={todo.id}> {todo.text} <button data-delete data-id={todo.id}>Delete</button> </li> ))} </ul> ); }

One listener on <ul> handles deletes for every item. When todos are added or removed, no listener setup is needed. Bubbling carries each click up to the parent automatically.

Advanced: stopPropagation() does not undo capturing

js
const parent = document.getElementById('parent'); const child = document.getElementById('child'); // Capture on parent runs first, before child gets anything parent.addEventListener('click', () => console.log('parent capture'), true); // Child stops bubbling after handling child.addEventListener('click', e => { e.stopPropagation(); console.log('child handler'); }); // This never runs - bubbling was stopped parent.addEventListener('click', () => console.log('parent bubble')); // Click on child outputs: // parent capture <-- capture already ran before stopPropagation had any effect // child handler // (parent bubble is skipped)

Stopping bubbling does not undo what capturing already did. The capture handler on parent ran before the child even received the event. I've seen this catch experienced developers who assumed stopPropagation() was a full kill switch for everything.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?