Why events travel, and where two methods hijack the trip
For simple or mostly flat HTML, JavaScript event handling tends to be predictable. Confusion arrives once events start propagating through a hierarchy of elements, and preventDefault() and stopPropagation() are the usual tools people reach for to fix whatever goes wrong. Too often the approach is trial and error: try one, then the other, then both. The difference matters, and it starts with how events move at all.
Two eventing styles exist in every modern browser: capturing and bubbling. Capturing barely shows up in day-to-day code, yet it was the original event model in Netscape Navigator. Through IE's history, Microsoft supported only bubbling, and neither browser supported the other's model. The W3C eventually standardized support for both via a third argument to addEventListener(). That argument was initially a boolean, but modern browsers accept an options object whose capture property selects the phase:
someElement.addEventListener('click', myClickHandler, { capture: true | false });
Both the options object and its capture property are optional. When omitted, capture defaults to false, so bubbling is the default behavior.
The full path of an event
Every event — whether you care or not — begins at the window and travels downward through the DOM. That downward leg is the capturing phase, and it runs even when your only listener is registered for the bubbling phase.
Consider nested elements #A, #B, and #C:
<html>
<body>
<div id="A">
<div id="B">
<div id="C"></div>
</div>
</div>
</body>
</html>
With capturing listeners attached to each element:
document.getElementById('C').addEventListener(
'click',
function (e) {
console.log('#C was clicked');
},
true,
);
A click on #C does not materialize at the target. The event is dispatched from the window and propagates through this chain:
windowdocument<html><body>#A#B#C(the target)
At each stop, the browser checks whether any listener is registered for that element in the capturing phase. If not, the event moves on. Only when the event arrives at #C does the so-called target phase begin — that's the period where the event is at its destination element. Any listeners on #C itself, capturing or bubbling, fire during this phase. After the target phase, propagation flips direction entirely.
From #C, the event bubbles upward through #B, #A, <body>, <html>, document, and ultimately the window. At each of those ancestors, the browser asks whether any listener is listening for that event type in the bubbling phase. A single element can host listeners for both phases — addEventListener() called once with capture: true and once without — and both would fire, just during different parts of the event's journey.
A useful way to observe the mechanics is to register listeners on every element across both phases:
<html>
<body>
<div id="A">
<div id="B">
<div id="C"></div>
</div>
</div>
</body>
</html>
document.addEventListener(
'click',
function (e) {
console.log('click on document in capturing phase');
},
true,
);
// document.documentElement == <html>
document.documentElement.addEventListener(
'click',
function (e) {
console.log('click on <html> in capturing phase');
},
true,
);
document.body.addEventListener(
'click',
function (e) {
console.log('click on <body> in capturing phase');
},
true,
);
document.getElementById('A').addEventListener(
'click',
function (e) {
console.log('click on #A in capturing phase');
},
true,
);
document.getElementById('B').addEventListener(
'click',
function (e) {
console.log('click on #B in capturing phase');
},
true,
);
document.getElementById('C').addEventListener(
'click',
function (e) {
console.log('click on #C in capturing phase');
},
true,
);
document.addEventListener(
'click',
function (e) {
console.log('click on document in bubbling phase');
},
false,
);
// document.documentElement == <html>
document.documentElement.addEventListener(
'click',
function (e) {
console.log('click on <html> in bubbling phase');
},
false,
);
document.body.addEventListener(
'click',
function (e) {
console.log('click on <body> in bubbling phase');
},
false,
);
document.getElementById('A').addEventListener(
'click',
function (e) {
console.log('click on #A in bubbling phase');
},
false,
);
document.getElementById('B').addEventListener(
'click',
function (e) {
console.log('click on #B in bubbling phase');
},
false,
);
document.getElementById('C').addEventListener(
'click',
function (e) {
console.log('click on #C in bubbling phase');
},
false,
);
Depending on which element you click, the console output changes. Clicking the deepest element, #C, runs every one of the registered handlers:
"click on document in capturing phase"
"click on <html> in capturing phase"
"click on <body> in capturing phase"
"click on #A in capturing phase"
"click on #B in capturing phase"
"click on #C in capturing phase"
"click on #C in bubbling phase"
"click on #B in bubbling phase"
"click on #A in bubbling phase"
"click on <body> in bubbling phase"
"click on <html> in bubbling phase"
"click on document in bubbling phase"
What stopPropagation() actually cuts off
The name is literal. Calling event.stopPropagation() halts the event's continued travel to every element it would otherwise reach, in both directions. If you invoke it anywhere along the capturing phase, the event never reaches the target phase and never bubbles. If invoked during bubbling, capturing has already completed by definition, but the event stops rising from the point of the call.
That rule holds for most native DOM events — the "most" qualifier exists because on non-propagating events like focus, blur, load, and scroll, the call succeeds without changing anything.
Reusing the earlier markup, calling stopPropagation() in the capturing phase on #B yields a specific, shortened output:
"click on document in capturing phase"
"click on <html> in capturing phase"
"click on <body> in capturing phase"
"click on #A in capturing phase"
"click on #B in capturing phase"
Calling it during the bubbling phase on #A gives a different truncated trace:
"click on document in capturing phase"
"click on <html> in capturing phase"
"click on <body> in capturing phase"
"click on #A in capturing phase"
"click on #B in capturing phase"
"click on #C in capturing phase"
"click on #C in bubbling phase"
"click on #B in bubbling phase"
"click on #A in bubbling phase"
What about calling it in the target phase, directly from #C's handler?
"click on document in capturing phase"
"click on <html> in capturing phase"
"click on <body> in capturing phase"
"click on #A in capturing phase"
"click on #B in capturing phase"
"click on #C in capturing phase"
Notice that #C's capturing-phase handler still executes and logs its message — that handler made the call. But #C's bubbling-phase handler never runs, because the event's travel ceased at the very call site.
The live demos reward experimentation: clicking only #A, only <body>, or any intermediate element makes the path and stop points predictable.
One step further: stopImmediatePropagation()
stopImmediatePropagation() is the stricter sibling, and it differs in scope, not direction. Rather than halting descent or ascent through the tree, it only applies to multiple listeners registered on the same element. addEventListener() is multicast-capable, so the same element can hear the same event type several times. Handlers then execute in registration order (as in most browsers). Calling stopImmediatePropagation() from within one of them blocks every subsequently registered handler on that element.
Example listeners:
<html>
<body>
<div id="A">I am the #A element</div>
</body>
</html>
document.getElementById('A').addEventListener(
'click',
function (e) {
console.log('When #A is clicked, I shall run first!');
},
false,
);
document.getElementById('A').addEventListener(
'click',
function (e) {
console.log('When #A is clicked, I shall run second!');
e.stopImmediatePropagation();
},
false,
);
document.getElementById('A').addEventListener(
'click',
function (e) {
console.log('When #A is clicked, I would have run third, if not for stopImmediatePropagation');
},
false,
);
The output confirms the behavior:
"When #A is clicked, I shall run first!"
"When #A is clicked, I shall run second!"
The third listener never fires because the second handler calls e.stopImmediatePropagation(). Calling e.stopPropagation() in the same spot would still let the third handler run — propagation to other elements stops, but listeners already attached to this element finish in order.
Stopping propagation vs. preventing the default action
stopPropagation() controls how an event travels through the DOM — it stops the event from moving further down (capturing) or further up (bubbling) the tree. preventDefault(), despite sounding similar, does something entirely different: it prevents the browser's default action for that particular event.
The default action depends on both the element and the event type. Sometimes there is no default action at all. For example, when you click an <a> element, the default action is navigation to the URL in its href attribute. Calling preventDefault() in a click handler on that anchor stops that navigation from happening:
<a id="avett" href="https://www.theavettbrothers.com/welcome">The Avett Brothers</a>
document.getElementById('avett').addEventListener(
'click',
function (e) {
e.preventDefault();
console.log('Maybe we should just play some of their music right here instead?');
},
false,
);
In this case, clicking "The Avett Brothers" link would normally navigate the browser, but instead the handler logs a message and nothing else happens.
There are many other element/event combinations with default actions that can be prevented. Here are a few common ones:
<form>+ "submit": prevents the form from submitting. Useful for conditionally blocking submission when client-side validation fails.<a>+ "click": prevents navigation to the URL in thehrefattribute.document+ "mousewheel": prevents mousewheel scrolling (keyboard scrolling still works). RequiresaddEventListener()with{ passive: false }.document+ "keydown": prevents keyboard scrolling, tabbing, and keyboard highlighting — this effectively renders the page useless.document+ "mousedown": prevents mouse-based text highlighting and other mousedown default actions.<input>+ "keypress": prevents typed characters from reaching the input. There is rarely a valid reason to do this.document+ "contextmenu": prevents the browser context menu from appearing on right-click or long-press.
This list is not exhaustive, but it illustrates the range of behaviors preventDefault() can control.
Combining both methods for maximum havoc
What happens if you call stopPropagation() and preventDefault() together during the capturing phase at the document level? The result is a page that is almost completely non-functional:
function preventEverything(e) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
}
document.addEventListener('click', preventEverything, true);
document.addEventListener('keydown', preventEverything, true);
document.addEventListener('mousedown', preventEverything, true);
document.addEventListener('contextmenu', preventEverything, true);
document.addEventListener('mousewheel', preventEverything, { capture: true, passive: false });
All events originate at window, so this handler stops click, keydown, mousedown, contextmenu, and mousewheel events before they ever reach any elements that listen for them. The call to stopImmediatePropagation also blocks any handlers registered on the document after this one.
The page-breaking effect comes mostly from preventDefault(). The stopPropagation() calls prevent events from reaching their intended targets, while preventDefault() suppresses all default actions — scrolling, text selection, keyboard navigation, link clicks, context menus — leaving the page essentially inoperable. It's not something you'd normally want to ship, but it's a useful mental exercise for understanding how these APIs interact.



