cf.completefrontend
ALGORITHMS IN FRONTEND WORK · CORE

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.

Time O(d * L)Space O(d)4 practice problemsStep-through picture

The 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.

How the example maps to the code
In the exampleIn the code
The person the letter is forThe target, targetId
The route from front desk to officepath, built from parentOf links and reversed
People who read letters on the way inListeners with capture: true, run on the walk down
People who read replies on the way outListeners with capture: false, run on the walk up
Stamping "stop"event.stopPropagation()

From the slow way to event propagation path

  1. 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.
  2. 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 parentOf until there is no parent lists exactly the elements that matter, in d steps for a target d levels deep.
  3. 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.
  4. 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: true listeners, 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

  1. Build path by starting at targetId and following parentOf until there is no parent. This lists the target first and the root last.
  2. Reverse path so it runs from the root down to the target.
  3. Capture phase: walk path from the first element to the last, and at each one run its listeners that have capture: true, in the order they were added.
  4. Bubble phase: walk path from the last element (the target) back to the first, and at each one run its listeners with capture: false.
  5. The target is the last stop going down and the first stop going up, so its capture listeners run before its normal ones.
  6. 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.

Loading the picture…
See every step at onceThe same run, laid out side by side
Drawing every step…

The code

Point at a note to light up the lines it explains.

  1. function dispatchOrder(parentOf, targetId, listeners) {
  2. const path = [];
  3. for (let node = targetId; node !== null; node = parentOf[node] ?? null) {
  4. path.push(node);
  5. }
  6. path.reverse();
  7. const calls = [];
  8. const runAt = (node, capture) => {
  9. for (const listener of listeners) {
  10. if (listener.node === node && listener.capture === capture) {
  11. calls.push(listener.name);
  12. }
  13. }
  14. };
  15. for (let i = 0; i < path.length; i++) runAt(path[i], true);
  16. for (let i = path.length - 1; i >= 0; i--) runAt(path[i], false);
  17. return calls;
  18. }

How fast it is

TimeO(d * L)
Extra memoryO(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 caseO(d)

    No listeners at all (L = 0): the climb, the reverse, and the two loops each take d + 1 steps.

  • Average caseO(d * L)

    A typical click a few levels deep: each of the 2(d + 1) stops scans all L listeners.

  • Worst caseO(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 stopPropagation stops other listeners on the same element. It only stops the event from moving on; use stopImmediatePropagation for that.
  • Assuming every event bubbles. focus, blur, mouseenter, and mouseleave don't; use focusin and focusout if you need bubbling focus events.
  • Checking event.target in a delegated listener without closest. If the click lands on an icon inside the button, event.target is 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.

Not started

0 of 4 solved. Solve one to earn the bronze medal, half for silver, and all of them for gold.

Start practicing

Go 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`]

StepPhasenodepathRan herecallsWhy
1buildsave[save]-[]Start at the target and push it.
2buildtoolbar[save, toolbar]-[]parentOf.save is toolbar, so climb one level.
3buildpanel[save, toolbar, panel]-[]Climb again.
4buildapp[save, toolbar, panel, app]-[]parentOf.app is null, so the climb stops after the root.
5reverse-[app, panel, toolbar, save]-[]Flip the path so path[0] is the root and the target is last.
6captureapppath[0]audit[audit]Going down, app has one capture listener, so audit runs first of all.
7capturepanelpath[1]none[audit]panel only has a bubble listener, which must wait for the way up.
8capturetoolbarpath[2]none[audit]toolbar has no listeners at all, but it is still on the path.
9capturesavepath[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.
10bubblesavepath[3]submit[audit, ripple, submit]The bubble loop starts at the target, so submit runs right after ripple.
11bubbletoolbarpath[2]none[audit, ripple, submit]Nothing on toolbar.
12bubblepanelpath[1]close[audit, ripple, submit, close]Now panel's normal listener runs.
13bubbleapppath[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
ApproachTimeExtra memoryChoose it when
Event propagation pathO(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 elementO(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

1101001k10k100k1M116324864input size n
Steps as the input grows, for Event propagation path and the other approaches, on a scale where each line up is 10 times more steps.
  • Event propagation path O(d * L)
  • Listeners stored per element O(d + L)
  • Scan every element for "contains the target" O(n)
About how many steps for bigger inputs
Approachn = 10n = 1,000n = 1,000,000
Event propagation path1001 million1 trillion
Listeners stored per element202,0002 million
Scan every element for "contains the target"101,0001 million

Inputs that need care

5 tricky inputs
The target is the root: parentOf = { app: null }, targetId = "app"
path is [app]. The capture loop runs app'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 same capture flag is ignored.
An event that doesn't bubble, like focus or mouseenter
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 document can see focus events.
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. With stopImmediatePropagation, 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 the event.eventPhase values 1, 2, and 3 (capturing, at target, bubbling) are this algorithm's phases.
  • event.composedPath() returns the path from the target up to window, 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 onClickCapture and onClick handlers in capture and bubble order.

Related algorithms