Event propagation is how the browser decides which element handles a click, keypress, or any DOM event. Learn bubbling, capturing, delegation, and the traps that trip up senior engineers.
Picture a stone dropped into a still pond. The stone hits one specific point — but the ripple doesn't stay there. It expands outward in every direction, reaching the near shore, then the far shore. Now run that in reverse: before the stone hits, a signal travels inward from the far shore toward the exact point of impact, checking everything it passes through on the way. That is exactly how browser events work. When you click a button inside a div inside the body, the browser does not just fire an event on the button. It runs two journeys. The first journey is capturing — the event travels down from the document root toward the clicked element, passing through every ancestor on the way. The second journey is bubbling — once the event reaches the clicked element, it travels back up through every ancestor toward the root. The key insight: a single click triggers an event on every element in the path, not just the one you clicked. Most developers only ever experience the bubbling half — because that is where event listeners live by default. Understanding both halves, and knowing how to control them, is what separates junior developers from senior ones in interviews.
Every DOM event travels through three distinct phases in a fixed order. Understanding this order is the foundation of every event propagation interview question.
document
└── html
└── body
└── div#container
└── button ← you click here
Phase 1 — Capture: document → html → body → div → button
Phase 2 — Target: The event fires on button itself
Phase 3 — Bubble: button → div → body → html → document
The third argument to addEventListener controls which phase a listener runs in. Passing true registers a capture-phase listener. Omitting it or passing false registers a bubble-phase listener — which is the default almost every developer uses.
const button = document.querySelector('button')
const div = document.querySelector('div')
div.addEventListener('click', () => console.log('div — capture'), true)
div.addEventListener('click', () => console.log('div — bubble'), false)
button.addEventListener('click', () => console.log('button — target'))
// Click the button → logs:
// "div — capture"
// "button — target"
// "div — bubble"
When an event fires on an element, it then fires on that element's parent, then the parent's parent, all the way up to the document. This is bubbling — the event rises like a bubble through water.
<div id="outer">
<div id="inner">
<button id="btn">Click me</button>
</div>
</div>
document.getElementById('btn').addEventListener('click', () =>
console.log('button')
)
document.getElementById('inner').addEventListener('click', () =>
console.log('inner div')
)
document.getElementById('outer').addEventListener('click', () =>
console.log('outer div')
)
// Click the button → logs:
// "button"
// "inner div"
// "outer div"
The outer div handler fires even though the user clicked the button — because the click event bubbles through every ancestor. This is not a bug — it is the intended design.
Not every event bubbles. focus, blur, load, scroll, mouseenter, and mouseleave do not bubble by design. This is a common interview trap. Use focusin and focusout — which do bubble — when you need to delegate focus events.
event.stopPropagation() stops the event from travelling further up or down the DOM. The current element's handlers still finish, but no ancestor handlers fire.
document.getElementById('inner').addEventListener('click', (e) => {
e.stopPropagation()
console.log('inner — stops here')
})
document.getElementById('outer').addEventListener('click', () =>
console.log('outer — never runs')
)
// Click inner → logs: "inner — stops here"
// outer handler never fires
event.stopImmediatePropagation() goes one step further — it stops propagation AND prevents any other listeners on the same element from running.
const btn = document.getElementById('btn')
btn.addEventListener('click', (e) => {
e.stopImmediatePropagation()
console.log('first handler — stops everything')
})
btn.addEventListener('click', () =>
console.log('second handler — never runs')
)
// Click → logs: "first handler — stops everything"
The difference: stopPropagation stops vertical travel through the DOM tree. stopImmediatePropagation also stops horizontal travel across multiple listeners on the same element.
event.preventDefault() and event.stopPropagation() are completely independent. This is the most commonly confused pair in DOM interviews.
preventDefault blocks the browser's built-in action for that event — navigation for links, form submission, checkbox toggling. The event still bubbles normally. stopPropagation stops the event travelling through the DOM. The browser's default action still happens.
// Link still navigates — bubbling stopped, default NOT prevented
link.addEventListener('click', (e) => {
e.stopPropagation()
// page still navigates to href
})
// Bubbling continues to parent listeners — navigation prevented
link.addEventListener('click', (e) => {
e.preventDefault()
// ancestor handlers still fire
})
// Neither navigates NOR bubbles
link.addEventListener('click', (e) => {
e.preventDefault()
e.stopPropagation()
})
This pair appears in almost every senior frontend interview and trips up the majority of candidates.
event.target — the element the user actually interacted with. The origin of the event. It does not change as the event bubbles.
event.currentTarget — the element whose event listener is currently running. It changes at each step of propagation.
document.getElementById('outer').addEventListener('click', (e) => {
console.log(e.target) // #btn — where the click originated
console.log(e.currentTarget) // #outer — where this listener lives
})
Inside an arrow function used as an event listener, this refers to the outer scope — not the element. Use event.currentTarget instead of this when you need the element the listener is attached to.
// Regular function — 'this' equals the element the listener is on
btn.addEventListener('click', function(e) {
console.log(this === e.currentTarget) // true
})
// Arrow function — 'this' is NOT the element
btn.addEventListener('click', (e) => {
console.log(this) // undefined (strict mode) or outer scope
console.log(e.currentTarget) // btn — use this instead
})
Because events bubble, you can attach a single listener to a parent and handle events for all its children — including children added to the DOM after the listener was attached. This is event delegation.
Without delegation — a listener per item, breaks on dynamic content:
// Does not work for items added after this runs
document.querySelectorAll('.item').forEach(item => {
item.addEventListener('click', handleClick)
})
With delegation — one listener, works for all current and future children:
document.getElementById('list').addEventListener('click', (e) => {
if (e.target.matches('.item')) {
handleClick(e.target)
}
})
This works because click events on any .item bubble up to #list, where the single listener catches them.
The closest() fix — handles nested elements inside the delegated target:
// Problem: user clicks the <span> inside <li class="item">
// e.target is the <span>, not the <li>
// e.target.matches('.item') returns false — event is missed
// Solution: closest() walks up from e.target until it finds a match
document.getElementById('list').addEventListener('click', (e) => {
const item = e.target.closest('.item')
if (!item) return // click outside any .item — ignore
handleClick(item)
})
closest() starts at e.target and walks up the DOM tree, returning the first ancestor that matches the selector — including the element itself. This handles any level of nesting inside the delegated element and is the correct delegation pattern in production code.
You can create and dispatch your own events that participate in the propagation system. The most common mistake is forgetting that CustomEvent defaults to bubbles: false.
// Dispatches but does NOT bubble — parent listeners never fire
const broken = new CustomEvent('cart:updated', {
detail: { itemId: 42 }
})
button.dispatchEvent(broken)
// Correct — explicitly enable bubbling for delegation to work
const event = new CustomEvent('cart:updated', {
bubbles: true,
cancelable: true,
detail: { itemId: 42, quantity: 3 }
})
button.dispatchEvent(event)
// Now this listener on document fires correctly
document.addEventListener('cart:updated', (e) => {
console.log(e.detail.itemId) // 42
})
removeEventListener requires the exact same function reference used in addEventListener. Arrow functions defined inline are new references on every call, so they can never be removed.
// Leaks — a new arrow function is created each time setup() runs
function setup() {
element.addEventListener('click', (e) => handleClick(e))
}
// This removes nothing — different reference
function teardown() {
element.removeEventListener('click', (e) => handleClick(e))
}
// Correct — store and reuse the same reference
function setup() {
element.addEventListener('click', handleClick)
}
function teardown() {
element.removeEventListener('click', handleClick)
}
The { once: true } option is the clean alternative when the handler should only fire once — it removes itself automatically after the first invocation.
button.addEventListener('click', handleClick, { once: true })
// Fires once, then removes itself — no teardown neededMany devs think preventDefault() stops the event from bubbling — but actually these two methods are completely independent. preventDefault blocks the browser's built-in action for that event. The event continues to bubble normally. You need stopPropagation for that, and they can be combined or used separately.
Many devs think all DOM events bubble — but actually focus, blur, load, scroll, mouseenter, and mouseleave do not bubble by design. Attaching a delegated focus listener to a parent silently does nothing. Use focusin and focusout instead, which are the bubbling equivalents.
Many devs think event.target changes as the event bubbles — but actually event.target is always the original element where the event was dispatched. It is fixed for the entire journey. Only event.currentTarget changes, pointing to whichever element's listener is currently executing.
Many devs think e.target.matches() is sufficient for delegation — but actually when the delegated element has children, the user may click a nested child. e.target is then the inner element, not the container you intended. e.target.closest('.selector') is the correct and robust pattern that handles any level of nesting.
Many devs think event delegation only works for elements that exist at the time the listener is attached — but actually delegation works for any element matching the selector that fires an event, including elements added to the DOM after the listener was attached. This is the primary advantage of delegation over per-element listeners.
Many devs think stopPropagation in a child prevents all parent listeners from running — but actually if a parent has a listener registered in the capture phase (third argument true), it already ran before the child's handler even fired. stopPropagation in the bubble phase cannot retroactively prevent a capture handler that already executed.
Many devs think CustomEvent bubbles by default — but actually new CustomEvent('name') sets bubbles: false. An event dispatched from a deeply nested element will not reach any ancestor listener unless you explicitly pass { bubbles: true }. This is one of the easiest bugs to introduce and hardest to debug in component-based architectures.
React's synthetic event system is built entirely on delegation — React attaches a single listener to the root container and routes all events internally. When you write onClick in JSX, React handles delegation for you, which is why stopping propagation between React handlers and native DOM listeners sometimes requires e.nativeEvent.stopImmediatePropagation() instead of e.stopPropagation().
Virtual scroll and infinite list implementations rely on delegation to avoid attaching thousands of event listeners to individual rows — libraries like TanStack Virtual render only visible rows, and delegation ensures clicks work regardless of which items are currently mounted in the DOM.
Analytics and tracking libraries like Segment and Mixpanel use document-level delegation to capture all clicks on the page without modifying application code — the data-track attribute pattern, where button elements carry data-track="signup-cta", is read by a single delegated listener on document.
Modal and dropdown close-on-outside-click is implemented with a delegated listener on document — the modal's own click handler calls stopPropagation so the click never reaches document, while all outside clicks bubble up and trigger the close logic. This is a pattern every senior frontend developer should implement from scratch in an interview.
Dynamic UI frameworks like HTMX use event delegation extensively — elements loaded over the network after page load automatically respond to interactions because delegation catches events from children regardless of when they were inserted, without requiring any re-initialization of JavaScript.
The Chrome DevTools Performance tab shows event listener counts — a page with 200 list items each having a click listener contributes 200 listeners to the page memory footprint. Refactoring to a single delegated listener is measurable in DevTools and is a concrete performance optimization interviewers ask candidates to identify.
Explain event delegation and why it is useful.
What is the difference between event bubbling and event capturing?
How do you create and dispatch Custom Events?
Reading answers is not the same as knowing them. Practice saying them out loud with AI feedback — that's what builds real interview confidence.