EventsGENERALBROWSER-SPECIFICFRAMEWORK-SPECIFIC

preventDefault vs stopPropagation

Two operations that share nothing: one cancels what the browser was about to do, the other stops the event travelling. Reaching for the wrong one breaks somebody else's feature, silently.

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.

The question

The browser did something I did not want, or my handler ran twice — which of these two calls is the fix, and what does the other one break?

The user intent

Someone clicks a link inside a card, or presses Enter in a search box. They expect exactly one thing to happen: the specific thing they aimed at.

The obvious build

When an event does something unwanted, call both. e.preventDefault(); e.stopPropagation(); is the incantation that makes the problem go away, and it is what every answer online shows.

Why it breaks

They do completely unrelated things. preventDefault() cancels the browser's built-in response; stopPropagation() cancels the rest of the *walk*. Calling both when you needed one is not caution — it is two changes, one of which you did not reason about.

How it breaks in a real browser
  • They do completely unrelated things. preventDefault() cancels the browser's built-in response; stopPropagation() cancels the rest of the *walk*. Calling both when you needed one is not caution — it is two changes, one of which you did not reason about.
  • stopPropagation() in a component silently breaks every ancestor listener: the app's delegated handlers, the analytics click tracker, the dropdown's dismiss-on-outside-click, the modal's focus trap. None of them error; they simply stop being called (Event Delegation).
  • preventDefault() on mousedown cancels focus and text selection, so a "stop the button stealing focus" fix quietly removes the ability to select the text next to it.
  • preventDefault() on keydown for Space stops the page scrolling — desirable inside a custom control, a serious bug when the event came from a text field, where it stops the user typing a space (Keyboard Events).
  • On a non-cancelable event, preventDefault() does nothing at all and reports nothing. scroll and input are not cancelable; a wheel listener that was made passive is not cancelable either (Passive Listeners).
  • return false behaves differently depending on how the handler was registered. In an inline onclick attribute it prevents the default; in an addEventListener callback it does absolutely nothing.
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • Every event carries a cancelable flag. preventDefault() sets defaultPrevented only if that flag is true; otherwise the call is a no-op and the browser proceeds. Nothing throws.
  • The default action is the browser's own behaviour for that event on that element: follow the link, submit the form, toggle the checkbox, show the context menu, scroll the page, start a text selection, begin a native drag.
  • The default action runs after the whole propagation walk finishes. Any listener on the path — including one far above the target — can still cancel it after your handler has run (How an Event Is Dispatched).
  • stopPropagation() removes the remaining nodes from the walk. Listeners already invoked have run; listeners on the current node still run; everything further along the path never sees the event.
  • stopImmediatePropagation() additionally skips the other listeners on the *same* node — including ones registered by code you do not own, which is why it is the sharpest tool in the module.
  • The two are genuinely orthogonal: stopPropagation() does not cancel the default action, and preventDefault() does not stop propagation. A cancelled click still bubbles all the way to Window, carrying defaultPrevented: true for anyone who cares to look.

What this makes the browser do

And which of it is avoidable.

  • Cancelling a default action removes browser work that was about to happen — a navigation, a form submission, a scroll — which is occasionally the point and occasionally the bug.
  • Cancelling a scroll is the expensive case, because the browser has to wait for your handler before it knows whether the scroll may proceed. That wait is what passive listeners exist to remove (Passive Listeners).
  • stopPropagation() saves the remaining listener invocations. That saving is never the reason to use it; the path is short and the calls are cheap.
  • Replacing a cancelled default with a JavaScript reimplementation — your own navigation, your own selection, your own scrolling — moves work from browser-internal code onto the main thread, and typically loses the accessibility behaviour that came with it (What Native Elements Already Do).

Two calls, nothing in common

The table is the whole lesson. If it is memorised, most of the bugs in this lesson stop being possible — and the crucial row is the last one, because it is the reason stopPropagation() is so much more dangerous than it looks: it takes effect in code you did not write and cannot see from the file you are editing.

Note also that neither call is loud. The platform will not warn you that a cancel was ignored on a non-cancelable event, and it certainly will not warn the author of the ancestor listener that their handler is no longer being reached.

  • A cancelled click still bubbles to Window. That is a feature: it is how an ancestor can know a descendant handled the interaction.
  • e.cancelable is worth logging once. On scroll, input, and any listener the browser made passive, preventDefault() is a no-op.
  • return false is not a third option. From addEventListener, the return value goes nowhere.
preventDefault()stopPropagation()stopImmediatePropagation()
What it cancelsThe browser's built-in responseThe rest of the propagation pathThe rest of the path and the remaining listeners on this node
Affects other listeners?No — all still runYes — everything further along the pathYes — including siblings on the same node
Affects the default action?Yes, if cancelableNo — the link still navigatesNo — the link still navigates
Observable by othersevent.defaultPrevented === trueNothing. Ancestors simply never fireNothing. Nobody knows they were skipped
Silently does nothing whenThe event is not cancelable, or the listener is passiveNothing above needed the eventNothing above or beside needed the event
Blast radiusThis event's default onlyEvery ancestor listener, including ones you do not ownEvery ancestor and sibling listener, including a library's

What each one breaks when it is the wrong choice

The example below is a card with a link inside it — the single most common place these two calls get confused. The card wants to navigate; the link inside it wants to navigate somewhere else; an ancestor is tracking clicks; an overlay is listening for outside clicks to dismiss itself. Four correct intentions, and only one arrangement of these calls that satisfies all of them.

The rule that falls out: a component cancels its own default and leaves the event alone. Coordination between layers happens through defaultPrevented, which is visible to everybody, rather than through silence, which is visible to nobody.

The card, the link, and everyone above them
1// WRONG — the incantation
2card.addEventListener('click', (e) => {
3 e.preventDefault() // cancels nothing: a div click has no default
4 e.stopPropagation() // breaks: root analytics, outside-click dismiss,
5 open(card.dataset.href!) // the router's delegated link handler
6})
7
8// RIGHT — cancel your own default, let the event travel
9card.addEventListener('click', (e) => {
10 if (e.defaultPrevented) return // a descendant already handled it
11 const link = (e.target as Element).closest('a')
12 if (link) return // the real link owns this click
13
14 e.preventDefault() // there is no default here, but it is the signal
15 open(card.dataset.href!) // ancestors still see the event,
16}) // and can see defaultPrevented
17
18// Client-side routing: cancel navigation ONLY where you can do it properly
19document.addEventListener('click', (e) => {
20 if (e.defaultPrevented) return
21 if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
22 const a = (e.target as Element).closest('a')
23 if (!a || a.target || a.hasAttribute('download')) return
24 if (new URL(a.href).origin !== location.origin) return
25
26 e.preventDefault() // now, and only now, we can honour the intent
27 router.navigate(a.getAttribute('href')!)
28})

Every guard in the routing handler is a browser behaviour you would otherwise delete: middle click, open-in-new-tab, downloads, and leaving your own origin. Cancelling a default means owning all of it.

Choosing, in one question

When an event is doing something you do not want, the diagnosis is a single question: is the unwanted thing the *browser* acting, or another *listener* acting? They have different fixes, and only one of the four options below is safe to reach for without thinking.

Something unwanted happened on this event

Is the browser doing it, or is another listener doing it?

The browser navigates, submits, toggles or scrolls — `preventDefault()`

when The unwanted behaviour is the platform's default action and you are replacing it with something equivalent.

cost You now own everything the default provided: keyboard activation, modifier keys, focus and the accessibility semantics (What Native Elements Already Do).

An ancestor listener also reacts — check the target there

when A parent handler fires for clicks that were really about a child. Fix it in the parent, where the knowledge belongs.

cost The parent gains a structural dependency on the child's markup — real coupling, but visible and greppable.

Layers need to coordinate — `defaultPrevented`

when A descendant handled the interaction and ancestors should stand down, but everyone should still see the event.

cost A convention the codebase must agree on; the browser does not enforce that ancestors check the flag.

Genuinely nothing above may see this — `stopPropagation()`

when Very rare, and essentially only inside a self-contained widget whose whole subtree you own and that nothing delegates through.

cost Every ancestor listener, present and future, silently stops firing. Analytics, dismissers and delegated handlers all break with no error (Event Delegation).

How to build it

Most important first.

  • Say which one you mean, out loud, before typing either. "I do not want the browser to navigate" is preventDefault(). "I do not want my parent to hear this" is almost never legitimate.
  • Treat stopPropagation() as a last resort. The problem it usually solves — an ancestor handler firing for a click that was really about a child — is better solved in the ancestor, by checking the target, than in the child, by hiding the event (Event Delegation).
  • Prefer event.defaultPrevented as the coordination channel between layers. A child cancels the default and lets the event through; the ancestor checks the flag and stands down. Everyone still sees the event.
  • Where the platform has a declarative answer, use it instead of cancelling: type="button" rather than preventing a submit, touch-action rather than a non-passive touchstart, user-select: none rather than cancelling selectstart.
  • When you cancel a link's navigation for client-side routing, honour the exceptions the browser was going to handle: modifier keys, middle click, target="_blank", download, and cross-origin hrefs (Client-Side Routing).
  • If you must cancel a keyboard default, scope it to the exact key and the exact element. A blanket keydown cancel on a container is how Tab, Escape and typing all stop working at once (Keyboard Operability).

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • Default actions *are* accessibility behaviour. Enter activating a link, Space activating a button, Tab moving focus, Escape closing a native dialog — cancelling any of them removes a keyboard affordance the user has no other way to reach (Keyboard Operability).
  • Cancelling mousedown to keep focus on a menu also cancels focus for the pointer path — a legitimate technique for a listbox, and a bug anywhere the user was expected to focus what they clicked (Focus Management).
  • A stopPropagation() on keydown inside a component can prevent a dialog above it from ever seeing Escape, leaving keyboard users trapped with no visible sign anything is wrong.
  • Never cancel Tab. A blanket keyboard cancel inside a focus trap that mishandles Shift+Tab is the most common way to strand a keyboard user in a modal (Accessible Component Patterns).
  • If you cancel a default in order to replace it, you own everything it did: role, focusability, activation keys, and the announcement. That is the trade being made, and it is a large one (Semantics Before ARIA).

What can go wrong

Failure modes
  • A dropdown that closes on outside click stops closing, because a card component inside it calls stopPropagation() on click. Two independent, correct-looking components; one broken feature.
  • Analytics coverage develops holes. Click tracking is delegated at the root, so every component that stops propagation is a region of the product with no data — and nobody notices, because missing events look like low usage.
  • preventDefault() on a form's submit with no replacement path. The form works when JavaScript is fine and does nothing at all when the handler throws before the fetch (Submission: Method, Encoding and Doing It Once).
  • preventDefault() on click for a checkbox reverts the checked state the browser had already applied optimistically, so the control visibly flips back — usually read as a state bug.
  • A stopImmediatePropagation() added to fix a duplicate handler, which then disables a listener a library registered on the same node in a later release.
  • The mitigation failing: replacing stopPropagation() with a target check in the ancestor, and writing the check against a class name that a redesign later renames.
What can arrive out of order
  • Calling preventDefault() after an await is too late — the synchronous dispatch has finished and the default action has already been decided (The Microtask Checkpoint).
  • Two listeners on the same node, one cancelling and one acting on defaultPrevented, are ordered by registration, which for lazily-loaded code is chunk load order (Code Splitting).
Security
  • Neither call is a security control. Cancelling a form submission does not stop the request from being made by other means, and the server must validate everything regardless (What the Frontend Is Responsible For in Auth).
  • Cancelling a default action that requires user activation can waste the activation: the browser considers the gesture spent, so a later window.open() or clipboard read in the same task may be blocked.
  • A third-party script can register a capture-phase listener and call stopImmediatePropagation(), removing your handlers entirely. Nothing in the platform prevents it (Third-Party Scripts and the Supply Chain).
  • preventDefault() on paste or copy is a UX choice, never a protection. Content in the DOM is fully available to the user and to anything running in the page (The Browser Security Model).
Misreads
  • "They do roughly the same thing, so calling both is safe." They share nothing. Calling both means you also made a change you did not think about, and it is usually the destructive one.
  • "stopPropagation() prevents the default action." It does not. A click that stopped propagating still navigates, still submits, still toggles — because the default runs after the walk, cancelled or not.
  • "preventDefault() stops other handlers running." It does not. Every listener on the path still fires; they can just see defaultPrevented is true.
  • "return false works." Only from an inline HTML attribute handler, and historically from jQuery, where it did both. From addEventListener the return value is discarded.
  • "If preventDefault() did not work, I called it too late." Sometimes — but far more often the event was not cancelable, or the listener was passive, and the platform told you nothing (Passive Listeners).
  • "stopPropagation() is a component encapsulation tool." It is the opposite: it reaches outside the component and silently disables listeners it knows nothing about.

Measuring it, and what changes in the field

How you would see this
  • Log e.type, e.cancelable, e.defaultPrevented and e.eventPhase in one place. A preventDefault() that does nothing is invisible until you print cancelable.
  • In the Elements panel's Event Listeners pane, walk the ancestors of a node that "stopped working" — the listener that swallowed the event is almost always visible one or two levels down.
  • Chromium logs a console warning when preventDefault() is called inside a passive listener. That warning is often the first clue that a cancel has been silently ignored for months (Passive Listeners).
  • A delegated analytics handler is an accidental detector: compare interaction counts against a control that is definitely used, and the gaps line up with components that stop propagation (Analytics Events That Answer a Question).
Slow device, slow network, large data, old tab
  • On a slow device, a cancelled scroll is far more visible, because the wait for the handler is longer relative to the frame (Scroll and Input Latency).
  • In a large codebase, stopPropagation() is worse than in a small one, purely because the number of ancestor listeners that could be depending on the event grows with the app.
  • With a third-party widget mounted inside your tree, both calls become negotiations with code you cannot change — and only one of the two, preventDefault(), leaves the event visible to everyone.
  • On touch, cancelling defaults interacts with gesture recognition: a cancelled touchstart suppresses the compatibility mouse events and the click that would have followed (Pointer Events).
What this costs
  • Cancelling a default and reimplementing it is sometimes genuinely necessary — client-side routing is the canonical case — and always costs you the platform behaviour you have to rebuild: keyboard, middle click, modifier keys, and the accessibility semantics.
  • Coordinating through defaultPrevented instead of stopPropagation() requires ancestors that actually check it, which is a convention the codebase has to agree on rather than a mechanism the browser enforces.
  • Fixing an over-eager ancestor by checking the target puts the knowledge of the child's structure into the parent — real coupling, in exchange for not breaking every other listener on the path.

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.

  • GENERALCancelability, defaultPrevented, and the fact that the default action runs after propagation are DOM specification behaviour; Blink, Gecko and WebKit agree, and disagreements here are treated as engine bugs.
  • BROWSER-SPECIFICDiagnostics differ sharply: Chromium logs an explicit console warning when preventDefault is ignored inside a passive listener, Firefox reports a similar but differently worded message, and Safari usually says nothing at all — so the same mistake is invisible depending on which browser you develop in.
  • FRAMEWORK-SPECIFICReact's synthetic stopPropagation stops the synthetic walk but not the native one, and a native listener attached outside React's root still fires; Vue, Svelte, Solid and Angular pass through to the real DOM event, so the same code has a different blast radius in each.

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

API Designidempotency
Domains that do not exist yet
  • Software Design — this is a coupling question wearing an event API: defaultPrevented is a published signal, stopPropagation() is a module reaching out and mutating a collaborator it never declared.