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
addEventListenerruns in the bubbling phase - Pass
trueas 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
['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 bubbleWhen 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
child.addEventListener('click', e => {
e.stopPropagation(); // stops bubbling
console.log('child handler');
});
// Capture listeners on ancestors already fired before this ranstopPropagation() 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
// 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
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
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:
onClickdelegates all events to the root container, not the individual element. That is whye.targetstays correct even inside async callbacks - Vue 3:
@click.captureopts into the capturing phase;@click.stopcallsstopPropagation()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')ore.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
<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)
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
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 readyA concise answer to help you respond confidently on this topic during an interview.