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 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?
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.
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.
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.
- 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()onmousedowncancels focus and text selection, so a "stop the button stealing focus" fix quietly removes the ability to select the text next to it.preventDefault()onkeydownfor 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.scrollandinputare not cancelable; a wheel listener that was made passive is not cancelable either (Passive Listeners). return falsebehaves differently depending on how the handler was registered. In an inlineonclickattribute it prevents the default; in anaddEventListenercallback it does absolutely nothing.
What is actually happening
In the browser, not in the framework.
- Every event carries a
cancelableflag.preventDefault()setsdefaultPreventedonly 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, andpreventDefault()does not stop propagation. A cancelled click still bubbles all the way toWindow, carryingdefaultPrevented: truefor 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.cancelableis worth logging once. Onscroll,input, and any listener the browser made passive,preventDefault()is a no-op.return falseis not a third option. FromaddEventListener, the return value goes nowhere.
| preventDefault() | stopPropagation() | stopImmediatePropagation() | |
|---|---|---|---|
| What it cancels | The browser's built-in response | The rest of the propagation path | The rest of the path and the remaining listeners on this node |
| Affects other listeners? | No — all still run | Yes — everything further along the path | Yes — including siblings on the same node |
| Affects the default action? | Yes, if cancelable | No — the link still navigates | No — the link still navigates |
| Observable by others | event.defaultPrevented === true | Nothing. Ancestors simply never fire | Nothing. Nobody knows they were skipped |
| Silently does nothing when | The event is not cancelable, or the listener is passive | Nothing above needed the event | Nothing above or beside needed the event |
| Blast radius | This event's default only | Every ancestor listener, including ones you do not own | Every 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.
1// WRONG — the incantation2card.addEventListener('click', (e) => {3 e.preventDefault() // cancels nothing: a div click has no default4 e.stopPropagation() // breaks: root analytics, outside-click dismiss,5 open(card.dataset.href!) // the router's delegated link handler6})7 8// RIGHT — cancel your own default, let the event travel9card.addEventListener('click', (e) => {10 if (e.defaultPrevented) return // a descendant already handled it11 const link = (e.target as Element).closest('a')12 if (link) return // the real link owns this click13 14 e.preventDefault() // there is no default here, but it is the signal15 open(card.dataset.href!) // ancestors still see the event,16}) // and can see defaultPrevented17 18// Client-side routing: cancel navigation ONLY where you can do it properly19document.addEventListener('click', (e) => {20 if (e.defaultPrevented) return21 if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return22 const a = (e.target as Element).closest('a')23 if (!a || a.target || a.hasAttribute('download')) return24 if (new URL(a.href).origin !== location.origin) return25 26 e.preventDefault() // now, and only now, we can honour the intent27 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.
Is the browser doing it, or is another listener doing it?
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).
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.
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.
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.defaultPreventedas 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-actionrather than a non-passivetouchstart,user-select: nonerather than cancellingselectstart. - 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
keydowncancel 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
mousedownto 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()onkeydowninside 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
- 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'ssubmitwith 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()onclickfor 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.
- Calling
preventDefault()after anawaitis 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).
- 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()onpasteorcopyis 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).
- "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 seedefaultPreventedis true. - "
return falseworks." Only from an inline HTML attribute handler, and historically from jQuery, where it did both. FromaddEventListenerthe return value is discarded. - "If
preventDefault()did not work, I called it too late." Sometimes — but far more often the event was notcancelable, 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
- Log
e.type,e.cancelable,e.defaultPreventedande.eventPhasein one place. ApreventDefault()that does nothing is invisible until you printcancelable. - 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).
- 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
touchstartsuppresses the compatibility mouse events and the click that would have followed (Pointer Events).
- 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
defaultPreventedinstead ofstopPropagation()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
stopPropagationstops 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.
- — Software Design — this is a coupling question wearing an event API:
defaultPreventedis a published signal,stopPropagation()is a module reaching out and mutating a collaborator it never declared.