Event propagation path
Work out which event listeners run, and in what order, by building the path from the root to the target and walking it down and back up.
O(d * L)Space O(d)4 practice problemsStep-through pictureThe idea
When you click a button, the browser doesn't only tell the button. It builds a path: every ancestor from the top of the page down to the button. The button is the target. Then the event travels that path in three phases.
In the capture phase it goes down from the root toward the target, running listeners added with { capture: true }. In the target phase it reaches the target itself. In the bubble phase it goes back up from the target to the root, running normal listeners. At the target, capture listeners run first, then normal ones. event.composedPath() returns this path, target first.
The algorithm is simple: climb from the target to the root using parent links to build path, flip it so it runs root first, then walk it forward for capture listeners and backward for bubble listeners. Each element's listeners run in the order they were added.
An everyday example
A letter delivered through a building
A letter comes for a person in office 4B. It goes from the building's front desk to the 4th-floor reception, then to office 4B. Anyone who asked to see letters on the way in reads it in that order. After the person reads it, the reply goes back out: office 4B, the floor reception, then the front desk, and anyone who asked to see replies reads it in that order. Someone can stamp "stop" on it, and it goes no further.
| In the example | In the code |
|---|---|
| The person the letter is for | The target, targetId |
| The route from front desk to office | path, built from parentOf links and reversed |
| People who read letters on the way in | Listeners with capture: true, run on the walk down |
| People who read replies on the way out | Listeners with capture: false, run on the walk up |
| Stamping "stop" | event.stopPropagation() |
From the slow way to event propagation path
- Start with the simplest idea: run the listeners on the target and nothing else. That breaks event delegation, where one listener on a list handles clicks on all its items. Another simple idea is to check every element on the page and ask "does it contain the target?". That costs time for every element on the page, on every click, and still doesn't say in what order to run them.
- The useful fact is that only the target and its ancestors can hear the event. And each element knows its parent. So climbing from the target through
parentOfuntil there is no parent lists exactly the elements that matter, indsteps for a targetdlevels deep. - Next, the order. The capture phase runs from the outside in, root first. The bubble phase runs from the inside out, target first. The climb gives inside-out order. Reverse it once, and the same array read forward gives capture order, and read backward gives bubble order.
- Last, build the whole path before running any listener, because the browser fixes the path when the event is dispatched. Then run two loops: forward, running only
capture: truelisteners, and backward, running only the others. The target is the end of the first loop and the start of the second, so its capture listeners run first. That pair of loops is the event propagation path.
How it works
- Build
pathby starting attargetIdand followingparentOfuntil there is no parent. This lists the target first and the root last. - Reverse
pathso it runs from the root down to the target. - Capture phase: walk
pathfrom the first element to the last, and at each one run its listeners that havecapture: true, in the order they were added. - Bubble phase: walk
pathfrom the last element (the target) back to the first, and at each one run its listeners withcapture: false. - The target is the last stop going down and the first stop going up, so its capture listeners run before its normal ones.
- Return
calls, the names of the listeners in the order they ran.
Watch it run
This picture runs the code below on a real input. Press Step to move one step at a time, or Play to watch. The highlighted line is the one that just ran. Change the input to see other cases.
See every step at onceThe same run, laid out side by side
The code
Point at a note to light up the lines it explains.
function dispatchOrder(parentOf, targetId, listeners) {const path = [];for (let node = targetId; node !== null; node = parentOf[node] ?? null) {path.push(node);}path.reverse();const calls = [];const runAt = (node, capture) => {for (const listener of listeners) {if (listener.node === node && listener.capture === capture) {calls.push(listener.name);}}};for (let i = 0; i < path.length; i++) runAt(path[i], true);for (let i = path.length - 1; i >= 0; i--) runAt(path[i], false);return calls;}
How fast it is
| Time | O(d * L) |
|---|---|
| Extra memory | O(d) |
The path has d elements, one per level from the root to the target, and each stop scans the L listeners. Storing listeners per element would make it O(d + L).
- Best case
O(d)No listeners at all (
L = 0): the climb, the reverse, and the two loops each taked + 1steps. - Average case
O(d * L)A typical click a few levels deep: each of the
2(d + 1)stops scans allLlisteners. - Worst case
O(d * L)A deeply nested target and a long list of listeners. Storing listeners per element, as browsers do, brings it down to
O(d + L).
Common mistakes
- Thinking the event starts at the target. Capture listeners on ancestors run before anything on the target.
- Thinking
stopPropagationstops other listeners on the same element. It only stops the event from moving on; usestopImmediatePropagationfor that. - Assuming every event bubbles.
focus,blur,mouseenter, andmouseleavedon't; usefocusinandfocusoutif you need bubbling focus events. - Checking
event.targetin a delegated listener withoutclosest. If the click lands on an icon inside the button,event.targetis the icon, not the button.
When to use it
Look for these clues in a problem:
- The question involves event listeners on nested elements and asks which run, or in what order.
- It mentions capture, bubble,
stopPropagation, or event delegation. - You need the chain of ancestors of an element, from the element up or from the root down.
- You need to know whether a click inside one element counts as inside another.
Check yourself
Answer each one in your head first, then open it.
Why build the whole path before running any listener?
The browser fixes the path when the event is dispatched. If a listener moves or removes an element, the event must still visit the original path, so the path can't be computed while listeners run.
A target is 5 levels below the root. How many elements are on its path?
6: the target plus its 5 ancestors. The climb pushes one element per level, including the root.
Why does a capture listener on the root run before any listener on the target?
The capture phase starts at path[0], the root, and walks down. The target's listeners only run when that walk reaches the end of the path.
If parentOf had a loop by mistake, like a pointing to b and b to a, what would happen?
The climb would never reach null, so the first loop would run forever. A real DOM can't have a loop, but if the data is untrusted, stop after a set number of steps or keep a seen set.
Practice problems
Stuck on one? Tuto is in the playground too, and gives you hints, never the answer.
0 of 4 solved. Solve one to earn the bronze medal, half for silver, and all of them for gold.
Start practicingGo further
You don't need these to start practicing. Open one when you want more.
Why it works
The reasoning that makes it always right
The first loop keeps this rule: path lists the target and its ancestors, from the target up to node. Each step moves to the parent, and the loop stops when the parent is null, which only happens at the root. A tree has no loops, so the climb ends after exactly d + 1 steps, where d is the target's depth. After path.reverse(), path[0] is the root and the last item is the target.
The DOM's rule is: capture listeners from the root down to the target, then normal listeners from the target back up. The forward loop visits path[0] to the last index, which is root to target, and runAt(..., true) keeps only capture listeners. The backward loop visits the indexes in the other order with capture === false. Inside runAt, scanning listeners from the start keeps the order each element's listeners were added, which the DOM also promises.
At the target, the forward loop finishes and the backward loop starts, so the target's capture listeners run before its normal ones, as in the trace. Older browsers ran target listeners in the order they were added, whatever their capture flag; current browsers run capture listeners first, which is what this code does. Because the path is built before any listener runs, a listener that moves elements can't change who hears this event.
Going deeper
3 more ideas
Why the path is fixed before any listener runs
The browser builds the path once, when the event is dispatched. If a listener moves or removes an element during the event, the event still travels the original path. That is why the algorithm builds path first and only then runs listeners.
Stopping, and events that don't bubble
event.stopPropagation() lets the other listeners on the current element finish, then stops the event from reaching any other element. event.stopImmediatePropagation() stops even the remaining listeners on the current element. Some events, like focus and mouseenter, don't bubble: they run the capture phase and the target, but skip the walk back up.
How fast it is
Building the path climbs d ancestors, where d is the target's depth. In this simple version, each stop scans the whole listeners list, so it is O(d * L) for L listeners. Browsers store listeners on each element, so each stop only looks at its own listeners and the total is O(d + L).
Work it by hand
Every step in a table, with why
Click a save button inside a toolbar inside a panel inside the app. Two listeners sit on the button, and one of them was added before the other but runs after it.
parentOf = { save: "toolbar", toolbar: "panel", panel: "app", app: null }, targetId = "save", listeners = [save bubble `submit`, app capture `audit`, panel bubble `close`, save capture `ripple`, app bubble `track`]
| Step | Phase | node | path | Ran here | calls | Why |
|---|---|---|---|---|---|---|
| 1 | build | save | [save] | - | [] | Start at the target and push it. |
| 2 | build | toolbar | [save, toolbar] | - | [] | parentOf.save is toolbar, so climb one level. |
| 3 | build | panel | [save, toolbar, panel] | - | [] | Climb again. |
| 4 | build | app | [save, toolbar, panel, app] | - | [] | parentOf.app is null, so the climb stops after the root. |
| 5 | reverse | - | [app, panel, toolbar, save] | - | [] | Flip the path so path[0] is the root and the target is last. |
| 6 | capture | app | path[0] | audit | [audit] | Going down, app has one capture listener, so audit runs first of all. |
| 7 | capture | panel | path[1] | none | [audit] | panel only has a bubble listener, which must wait for the way up. |
| 8 | capture | toolbar | path[2] | none | [audit] | toolbar has no listeners at all, but it is still on the path. |
| 9 | capture | save | path[3] | ripple | [audit, ripple] | At the target, only the capture listener runs here. submit was added before ripple, but it is not a capture listener. |
| 10 | bubble | save | path[3] | submit | [audit, ripple, submit] | The bubble loop starts at the target, so submit runs right after ripple. |
| 11 | bubble | toolbar | path[2] | none | [audit, ripple, submit] | Nothing on toolbar. |
| 12 | bubble | panel | path[1] | close | [audit, ripple, submit, close] | Now panel's normal listener runs. |
| 13 | bubble | app | path[0] | track | [audit, ripple, submit, close, track] | The root's normal listener runs last. The loop has reached index 0, so it ends. |
It returns [audit, ripple, submit, close, track]. The trace ends after the bubble loop reaches the root. The root ran both first and last, because it had one listener for each phase.
Compared with other ways
When to pick something else
| Approach | Time | Extra memory | Choose it when |
|---|---|---|---|
| Event propagation path | O(d * L) | O(d) | You need the exact order listeners run in, with capture and bubble, given parent links and a flat list of listeners. |
| Listeners stored per element | O(d + L) | O(d) | There are many listeners, or you dispatch many events. A map from element to its own listeners makes each stop only look at its own list, as browsers do. |
| Scan every element for "contains the target" | O(n) | O(n) | Almost never. It looks at the whole page on every event and still needs sorting to get the right order. |
Extra memory: O(d) for path, plus the calls list, which holds at most L names.
How the steps grow
- Event propagation path
O(d * L) - Listeners stored per element
O(d + L) - Scan every element for "contains the target"
O(n)
| Approach | n = 10 | n = 1,000 | n = 1,000,000 |
|---|---|---|---|
| Event propagation path | 100 | 1 million | 1 trillion |
| Listeners stored per element | 20 | 2,000 | 2 million |
| Scan every element for "contains the target" | 10 | 1,000 | 1 million |
Inputs that need care
5 tricky inputs
- The target is the root:
parentOf = { app: null },targetId = "app" pathis[app]. The capture loop runsapp's capture listeners, then the bubble loop runs its normal ones. One element, both phases.- A target with no listeners anywhere on its path
- Both loops visit every stop and push nothing, so the result is
[]. In a browser, the event still travels the whole path. - An element has two listeners for the same phase
- They run in the order they appear in
listeners, which is the order they were added. In a browser, adding the very same function twice with the samecaptureflag is ignored. - An event that doesn't bubble, like
focusormouseenter - Skip the ancestors in the second loop: run only the target's normal listeners. The capture loop still runs on every ancestor, which is why a capture listener on
documentcan seefocusevents. - A listener calls
stopPropagation() - This simple code has no such flag. To add it, set a flag when the listener runs, finish the other listeners on the same element, and then stop both loops.
Variations and follow-ups
Twists interviewers add
Stopping propagation
Give each call a way to set stopped. After runAt finishes an element, break out of the loop if stopped is true, so listeners on the same element still run. For stopImmediatePropagation, check the flag inside runAt too, so the rest of that element's listeners are skipped.
Event delegation
Put one listener on a list and use event.target.closest(".item") to find which item was clicked. It works because every click inside the list bubbles up through the list. It saves adding one listener per item, and it works for items added later.
Shadow DOM and composed events
When an event comes from inside a shadow root, listeners outside it see event.target as the host element, not the inner node. This is called retargeting. Events with composed: false don't leave the shadow root at all, and event.composedPath() shows the full path.
React's synthetic events
Since React 17, React attaches its listeners to the root container. When a native event arrives there, React builds its own path through its fiber tree from the target's component up to the root, then calls onClickCapture handlers going down and onClick handlers going up, the same two loops.
In an interview
Spot it, explain it, answer follow-ups
- Spot it from words like "capture", "bubble", "which handlers run and in what order", "
stopPropagation", or "delegation". The input usually has parent links and a list of listeners. - Explain the three phases first, in one sentence each, then say the trick: "I build the path once by climbing parents, reverse it, then read it forward for capture and backward for bubble".
- Test by hand with 2 listeners on the target, one capture and one normal, added in the opposite order. Many wrong answers run them in the order added.
- Expect the follow-up "what if a listener calls
stopPropagation?". Answer: the other listeners on the same element still run, then the event goes no further. WithstopImmediatePropagation, even the same element's remaining listeners are skipped.
Where frontend code uses it
The same idea in real UI code
addEventListener(type, handler, { capture: true })and theevent.eventPhasevalues1,2, and3(capturing, at target, bubbling) are this algorithm's phases.event.composedPath()returns the path from the target up towindow, including shadow roots when the event is composed.- Event delegation, one listener on a list instead of one per item, relies on bubbling plus
event.target.closest(".item"). - React attaches its listeners at the root container and walks its own fiber tree to call
onClickCaptureandonClickhandlers in capture and bubble order.