How an Event Is Dispatched
Hit-test to a target, build the propagation path, then walk it: capture down, at target, bubble up — with the path frozen before any listener runs.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
When a user clicks, how does the browser decide which code runs, and in what order?
Someone points at a thing on screen and acts on it — a tap, a click, a key. They expect the thing they pointed at to respond, and nothing else.
You attach a handler to the element and it runs when the element is clicked. Order does not come up, because there is only one handler.
There is never only one handler. A modal has an overlay listener, the app has an analytics listener on the root, the router intercepts link clicks, and a framework has one delegated listener for the whole tree — a single click runs all of them, in an order nobody chose deliberately.
- There is never only one handler. A modal has an overlay listener, the app has an analytics listener on the root, the router intercepts link clicks, and a framework has one delegated listener for the whole tree — a single click runs all of them, in an order nobody chose deliberately.
- The element you attached to is often not
event.target. Click the<span>inside a<button>and the target is the span; code that readsevent.target.dataset.idgetsundefinedfor a reason that looks like a data bug. - Some events do not bubble at all.
focus,blur,mouseenter,mouseleave,loadanderrornever reach an ancestor listener, so the delegated handler that works forclicksilently does nothing forfocus. - A handler that removes its own element mid-dispatch does not stop the event: the path was computed before the first listener ran, so the remaining listeners on nodes that are no longer in the document still fire.
- Hit-testing is not "the element under the cursor" in the DOM sense — it is paint order plus stacking plus the CSS
pointer-eventsproperty. A transparent overlay withz-indexsteals every click while looking like nothing at all (Positioning and Stacking Contexts).
What is actually happening
In the browser, not in the framework.
- The browser hit-tests the input coordinates against the painted, composited result — not against the DOM tree order. Stacking contexts,
z-index, transforms andpointer-eventsall take part, which is why the visually topmost thing is the thing that gets the event (Pointer Events). - It then builds the propagation path: the chain of nodes from that target up through its ancestors to the
Documentand theWindow. This happens once, at dispatch, and is frozen. Mutating the DOM inside a listener does not rewrite the path already in flight. - It walks the path in three phases. Capture runs from
Windowdown to the target's parent, firing only listeners registered withcapture: true. At target fires the target's own listeners, capture and bubble alike, in registration order. Bubble runs from the parent back up toWindow, firing non-capture listeners. event.targetis the node the event was dispatched at and never changes during the walk.event.currentTargetis the node whose listener is executing right now, and changes at every step.event.eventPhasetells you which of the three you are in.- Dispatch is synchronous.
dispatchEvent()runs every listener on the path to completion and returns before the next line — all of it on the one thread that also owns rendering (What the Main Thread Owns). - Shadow boundaries retarget. An event that crosses out of a shadow root reports the host element as
targetto outside listeners, so the internals stay encapsulated;composedPath()returns the real path if the event iscomposed(Shadow DOM and the Composed Tree). - After the walk, and only if nothing cancelled it, the browser runs the default action — navigate the link, submit the form, toggle the checkbox, scroll the page (preventDefault vs stopPropagation).
What this makes the browser do
And which of it is avoidable.
- Hit-testing every input point against the current composited frame, which is why input during a heavy layout can be answered late even though no JavaScript has run yet.
- Allocating and populating the event object and the path array once per dispatch, then invoking each listener as a JavaScript call on the main thread.
- Firing the whole
pointermove/mousemovestream at input-device rate unless you coalesce it. Movement events are the cheapest place in the browser to accidentally schedule work per pixel (The Frame Budget). - Keeping every listener alive as a GC root from its node. Listeners attached and never removed are a common retention path for entire detached subtrees (Memory Leaks).
- The avoidable part is almost always listener count and handler body, not dispatch itself. Dispatch of one event across a deep path is cheap; a hundred listeners each doing a layout read is not (Layout Thrashing).
From a coordinate to a call stack
A pointer event begins as a position, not as an element. The browser hit-tests that position against the composited frame it most recently produced — which means the answer depends on paint order and stacking, not on where a node sits in the markup. This is why an absolutely positioned sibling with a higher stacking order swallows clicks meant for the element it visually covers, and why an element with CSS pointer-events: none is invisible to hit-testing while remaining perfectly visible on screen.
Once a target node exists, the browser builds the propagation path and walks it. Everything after this point is ordinary synchronous JavaScript on the main thread: a sequence of function calls, one per matching listener, with a single shared event object threading through them.
The walk, step by step
The order below is the whole model. It is worth reading as an algorithm rather than a diagram, because each step has its own way of going wrong, and the failure usually appears at a completely different step from the cause.
- 1Determine the target
Hit-test a coordinate, or take the focused element for keyboard input, or take the node passed to
dispatchEvent().fails by An invisible overlay, a
pointer-eventsvalue you forgot, or nothing focused — in which case key events land on the body. - 2Build the path
Collect target, its ancestors, the Document and the Window into an ordered list, retargeting across any shadow boundaries.
fails by Nothing at dispatch time — but it fixes the path, so later DOM mutation cannot redirect an event already in flight.
- 3Capture phase
Walk from Window down to the target's parent, firing listeners registered with
capture: true.fails by A capture listener calling
stopPropagation(), which cancels the target's own handler before it ever runs. - 4At target
Fire all listeners on the target, capture and bubble flags alike, in the order they were registered.
fails by Duplicate registration from a re-render, so the same callback runs several times per click.
- 5Bubble phase
Walk back up to Window, firing non-capture listeners — the phase delegation depends on.
fails by A non-bubbling event type, or a descendant that called
stopPropagation()for a local reason. - 6Default action
Navigate, submit, toggle, scroll, focus — whatever the platform does for this event on this element.
fails by A stray
preventDefault(), or an event that was nevercancelableand quietly ignored the call.
Only two of these six steps involve your listeners. Four are the browser making decisions before and after your code gets a turn.
target, currentTarget and the trace nobody prints
The single most useful debugging habit in this module is to log the phase and both targets at every listener. It converts an argument about ordering into two lines of output, and it makes the frozen-path behaviour immediately obvious: currentTarget marches up the tree while target never moves.
The trace below is what the code produces for a click on a <span> inside a <button> inside a <form>, with a capture listener on the document and bubble listeners on the form and the button.
event.targetanswers "what did the user hit".event.currentTargetanswers "whose listener is this". They are equal only at the target.currentTargetisnullonce the dispatch is over — reading it inside anawaitcontinuation or asetTimeoutgives you nothing (Tasks: The Unit That Cannot Be Interrupted).composedPath()returns the full path including nodes inside shadow roots, and is the only way to see through retargeting (Shadow DOM and the Composed Tree).event.eventPhaseis1,2or3. If you have never printed it, print it once — it settles most ordering arguments in a single click.
1const trace = (label: string) => (e: Event) => {2 const phase = ['NONE', 'CAPTURING', 'AT_TARGET', 'BUBBLING'][e.eventPhase]3 console.log(label, phase, {4 target: (e.target as Element).localName, // never changes5 currentTarget: (e.currentTarget as Element | null)?.localName ?? 'window',6 composed: e.composed, // can it leave a shadow root?7 isTrusted: e.isTrusted, // user-generated, or dispatchEvent()?8 })9}10 11// capture: true means "see it on the way down"12document.addEventListener('click', trace('document'), { capture: true })13form.addEventListener('click', trace('form'))14button.addEventListener('click', trace('button'))15 16// One signal removes the whole group — far harder to leak than17// matching every addEventListener with a removeEventListener.18const ac = new AbortController()19button.addEventListener('click', onSave, { signal: ac.signal })20// later: ac.abort()The signal option is the part worth stealing. Cleanup is where listener bugs actually come from, and an AbortController turns n removals into one.
click on <span> inside <button type="submit"> inside <form> document CAPTURING target: span currentTarget: #document button AT_TARGET target: span currentTarget: button form BUBBLING target: span currentTarget: form → default action: submit the form Read it twice: target is the span, in every single line currentTarget is the node whose listener is running the button listener fires AT_TARGET, not "bubbling"
How to build it
Most important first.
- Read
event.currentTargetwhen you mean "the element I attached to" andevent.targetwhen you mean "the deepest thing the user actually hit". Nearly everytarget/currentTargetbug is this sentence, unwritten. - Use
closest()to walk from the target back to the element you care about, rather than assuming the target is it (Event Delegation). - Register on the capture phase only when you genuinely need to see the event before descendants — an outside-click dismisser, a global shortcut guard, an instrumentation hook. Capture is a strong claim and it makes ordering harder to reason about.
- Prefer the bubbling twin for events that do not bubble:
focusin/focusoutinstead offocus/blur, or registerfocuswithcapture: trueif you must. - Do not encode ordering assumptions between listeners on different nodes. If two things must happen in order, that is one handler calling two functions, not two handlers hoping.
- Use
AbortControllerwith a single signal to remove a group of listeners at once — it is far harder to leak than matching everyaddEventListenerwith a hand-writtenremoveEventListener.
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- Keyboard-generated events do not come from a coordinate — they are dispatched at
document.activeElement. A control that is not focusable receives no key events at all, which is why hit-testing intuition does not transfer (Keyboard Operability). - Assistive technology activation of a control produces a real, trusted
clickevent with no preceding pointer events. Logic that requires apointerdownbefore it will accept aclicklocks out screen-reader and switch users (Keyboard Events). - A capture-phase listener on
documentthat callsstopPropagation()can swallow the Escape and Tab handling of a dialog underneath it, breaking focus management for everyone but visibly for nobody (Focus Management). - Because dispatch is synchronous on the main thread, a slow handler delays focus moves and live-region announcements exactly as it delays paint (Live Regions and Announcement).
What can go wrong
- A transparent full-viewport overlay left mounted with
opacity: 0. Every click in the app hits it and nothing responds; the DOM looks correct and the CSS looks harmless. - A listener added on every render and never removed. Handlers pile up, the same click runs the callback n times, and n grows with how long the user has been on the page.
- A delegated root listener and a local listener both handling the same click, in an order that depends on where each was registered — so the behaviour changes when someone reorders imports.
- Assuming the path reflects the live DOM. A handler that unmounts a panel leaves later handlers running against detached nodes, where
getBoundingClientRect()returns zeros andclosest()walks a tree no longer attached to the document. - Relying on capture to "win".
stopPropagation()in a capture listener prevents the target's own handler from ever running, which is a very effective way to break a component you do not own (preventDefault vs stopPropagation).
- A listener that mutates the DOM changes what later listeners on the same dispatch observe, while the path itself does not change — so
contains()checks inside a bubble handler can disagree with the path it is travelling. - Two independent listeners on the same node fire in registration order, which for lazily-loaded modules is load order, which is not stable across builds (Code Splitting).
- An
awaitinside a handler ends the synchronous dispatch for that callback: the rest of the path runs, the default action is decided, and your continuation resumes afterwards with the DOM already changed (The Microtask Checkpoint).
event.isTrusteddistinguishes an event the user agent generated from one your script synthesised withdispatchEvent(). The browser enforces this flag; script cannot forge it.- A number of privileged actions — opening a window, entering fullscreen, reading the clipboard, starting playback with sound — require transient user activation, which only a trusted event grants and which expires. Synthetic clicks do not unlock them.
- Events do not cross origins. A listener in your page cannot observe input inside a cross-origin
<iframe>; conversely, an iframe you embed cannot read your key events (The Same-Origin Policy). - That guarantee is exactly what clickjacking attacks: the user's click is trusted and genuine, but aimed at a frame they cannot see (Clickjacking and Framing).
- Any script in your page can attach a capture listener on
documentand observe every keystroke and click in it. Third-party script is not partially trusted (Third-Party Scripts and the Supply Chain).
- "The event starts at the element and goes up." It starts at the
Windowgoing *down*. Capture happens first, every time, and it is where an outside-click handler gets to see the click before the component that will cancel it. - "
event.targetis the element I attached the listener to." That iscurrentTarget.targetis wherever the user actually hit, which is usually a text node's parent inside your component. - "If I remove the element, the event stops." The path was frozen at dispatch. Removal changes what the DOM looks like, not where the event is already going.
- "Capture and bubble are two different events." One dispatch, one event object, three phases.
stopPropagation()in the capture phase kills the bubble phase too, because there is nothing left to travel. - "React events are DOM events." They are synthetic events over a delegated root listener. Native
stopPropagation()on a node inside that root prevents the React handler from ever seeing it (The React Mental Model).
Measuring it, and what changes in the field
- The Elements panel's Event Listeners pane lists every listener on the selected node and its ancestors, with the registering source and whether it is capture or passive — the fastest answer to "what else runs on this click".
- In the Chrome console,
getEventListeners($0)dumps the listeners on the inspected node andmonitorEvents($0, 'click')logs dispatches live. Both are devtools-only helpers, not page APIs. - The Performance panel's interaction records show the input, the handlers that ran and the frame that followed, which is where "the click was handled, the pixel was late" becomes visible (Interaction Responsiveness).
- Growing listener counts across repeated navigations, visible in a heap snapshot's retained detached nodes, are the signature of listeners that were added and never removed (Debugging Memory).
- On a slow device the gap between the input and the handler widens, because hit-testing waits for a composited frame and the handler waits for the main thread to be free (Long Tasks).
- On a deep tree — a design system nesting a dozen wrappers around a button — the propagation path is long, so a root-level delegated listener is doing more traversal per event than the source suggests.
- On a page with thousands of interactive rows, per-row listeners multiply memory and registration cost even though each individual dispatch stays cheap (List Virtualization).
- In a long-lived single-page app, listener leaks accumulate across routes rather than being cleared by navigation, so the same click gets progressively more expensive over a session (Long-Lived Clients and Version Skew).
- Understanding phases costs a genuinely non-obvious mental model — most developers ship for years knowing only "it bubbles". The payback is that ordering bugs stop being mysteries and become a question you can answer by looking at one panel.
- Capture-phase listeners give you first refusal on an event, at the cost of making the ordering of an unfamiliar codebase harder to predict, especially for the next person debugging it.
- Removing listeners properly means threading an
AbortControlleror a cleanup function through component lifecycles — more plumbing than attaching and forgetting, and the only version that does not leak.
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- GENERALThe three phases, the frozen propagation path, target versus currentTarget and shadow retargeting are DOM specification behaviour and match across Blink, Gecko and WebKit; disagreements between engines here are bugs, not variation.
- BROWSER-SPECIFICThe inspection tooling is not portable:
getEventListenersandmonitorEventsare Chromium console helpers, Firefox exposes the same information through the event badge next to a node in the Inspector, and Safari lists listeners in the Node pane with less ancestor detail. - FRAMEWORK-SPECIFICReact attaches its listeners at the root container of the tree rather than on each element, so the node a native listener sits on decides whether it runs before or after a React handler; Vue, Svelte, Solid and Angular attach to the real element and do not have this ordering surprise.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Programming Languages & Runtime Internals — what a listener closure actually retains, and why a handler capturing one node can keep an entire detached subtree reachable.