skip to content

A touchmove listener added on document calls event.preventDefault() to stop the page scrolling, but the page scrolls anyway and the console warns that preventDefault cannot be used inside a passive listener. Why, and what are the options?

level: middleimportance: should knowfreq 42%

answer

  1. scroll-blocking input events
  2. compositor should not wait on JavaScript
  3. only on the root targets
  4. preventDefault becomes inert
  5. declare the gesture in CSS instead

basics

~20 s

Browsers make touchstart, touchmove and wheel listeners passive by default when they are registered on window, document, document.documentElement or document.body, so preventDefault() is ignored. Opt back in with { passive: false }, or block the gesture declaratively with CSS touch-action.

solid answer

~60 s

Scroll-blocking input events are the reason. Before a browser can scroll in response to a touch or wheel, it has to know whether a listener will cancel the gesture — and if that listener runs on the main thread, the compositor must wait for it, which is exactly how a busy page produces scroll jank. To break that, Chrome from version 56 (touch) and 73 (wheel), with matching behaviour in current Firefox and Safari, made `touchstart`, `touchmove` and `wheel` listeners **passive by default when the target is `window`, `document`, `document.documentElement` or `document.body`**. In a passive listener `preventDefault()` does nothing, `defaultPrevented` stays false, and the console warns. Two ways forward: register explicitly with `{ passive: false }` if you truly must cancel, accepting that the scroll now waits on your handler; or express the intent declaratively with the CSS `touch-action` property — `touch-action: none` on the element stops the browser owning the gesture at all, with no main-thread round trip. On an ordinary element rather than one of those four roots the default is still non-passive, which is why the same code behaves differently depending on where it was attached.

code

javascript · 11 lines
javascript
// Passive by default because the target is document: preventDefault is ignored.
document.addEventListener('touchmove', (event) => {
  event.preventDefault();
  console.log('cancelled?', event.defaultPrevented); // false
});

// Explicit opt-in restores cancellation, at the cost of gating the scroll.
document.addEventListener('touchmove', (event) => {
  event.preventDefault();
  console.log('cancelled?', event.defaultPrevented); // true
}, { passive: false });

go deeper

for a junior

Know that a passive listener cannot cancel the default, and that touchmove and wheel listeners on the document are passive unless you pass { passive: false }. Recognising the console warning is the main thing.

for a middle

Explain the mechanism: the compositor cannot start a scroll until it knows no handler will cancel it, so a possibly-cancelling main-thread listener forces a wait. Name the four targets the default applies to.

for a senior

Show the production judgment of preferring touch-action or overscroll-behavior for gestures your UI owns statically, keeping any non-passive handler tiny, and knowing this rule is why an innocuous listener can regress scroll performance.

for a principal

Own it as a policy question: a lint or review rule for non-passive scroll-blocking listeners, a shared gesture layer that declares touch-action rather than cancelling per event, and an agreed budget for main-thread work during scroll.

## The problem the default solves A touch or wheel gesture is *scroll-blocking*: the browser cannot start scrolling until it knows nobody is going to cancel it. Scrolling normally happens on the compositor thread and can keep up with the finger even while JavaScript is busy — but only if the compositor is allowed to proceed without asking. If the page has registered a `touchmove` or `wheel` listener that *might* call `preventDefault()`, the compositor must hand the event to the main thread, wait for the listener to return, and only then scroll. On a page with long tasks, that wait is visible as a stutter at the start of every scroll. The `passive` listener option is the promise that removes the wait: `{ passive: true }` declares "this handler will never cancel the default", so the browser scrolls immediately and runs the handler whenever it can. ## The default that surprises people Because an enormous number of pages had analytics or carousel listeners on the document that never cancelled anything, browsers changed the *default* rather than waiting for every site to add the option. Chrome 56 made `touchstart` and `touchmove` passive by default, and Chrome 73 did the same for `wheel` and `mousewheel`; Firefox and Safari ship equivalent behaviour today. The scope is the important detail, and it is narrow: the default flips **only when the listener target is `window`, `document`, `document.documentElement` or `document.body`**. Register the identical listener on a `<div>` and it is non-passive as before, and `preventDefault()` works. That is why the same helper function behaves differently depending on which node it was attached to — the single most confusing symptom of this rule. Inside a passive listener, cancellation is simply inert: ```js document.addEventListener('touchmove', (e) => { e.preventDefault(); // ignored console.log(e.defaultPrevented); // false }); // console: Unable to preventDefault inside passive event listener invocation. ``` ## Option one: opt back in ```js document.addEventListener('touchmove', onTouchMove, { passive: false }); ``` This restores cancellation, and restores the cost: the browser must now consult your handler before it can scroll, so keep the handler short and free of layout reads. Reserve it for cases where the decision to cancel genuinely depends on runtime state — a pull-to-refresh that only blocks when the container is already at scroll position zero, for example. Note that `{ passive: true }` is not a general performance win to sprinkle everywhere: it is meaningful only for scroll-blocking event types. Adding it to a `click` listener changes nothing. ## Option two: say it in CSS instead The declarative lever is the `touch-action` property, which tells the browser up front which gestures it may handle on an element: ```css .canvas { touch-action: none; } /* the app owns all gestures here */ .carousel { touch-action: pan-y; } /* vertical page scroll stays with the browser */ ``` Because it is known before the gesture starts, there is no main-thread round trip at all, and pointer events keep flowing to your handler instead of being cut off with `pointercancel` when the browser claims the gesture for panning. For a custom drag, slider or drawing surface this is the correct tool; `preventDefault()` in a non-passive `touchmove` is the fallback for decisions that can only be made mid-gesture. `overscroll-behavior: contain` is the related lever for the narrower problem of scroll chaining and pull-to-refresh. ## Wheel specifics `wheel` carries `deltaX`, `deltaY` and `deltaZ` plus `deltaMode`, which says what unit those deltas are in: `WheelEvent.DOM_DELTA_PIXEL` (0), `DOM_DELTA_LINE` (1) or `DOM_DELTA_PAGE` (2). Code that assumes pixels breaks on devices reporting lines. Trackpad pinch-zoom is also reported as a `wheel` event with `ctrlKey` set to true — the standard way a zoomable canvas distinguishes pinch from scroll. Cancelling a wheel to implement custom zoom requires the same `{ passive: false }` opt-in when the listener sits on one of the root targets. ## What to say in an interview Name the three parts: the default applies to `touchstart`, `touchmove` and `wheel`; only on the four root targets; and it exists because the compositor should not have to wait on the main thread to start a scroll. Then give both remedies — `{ passive: false }` for a runtime decision, `touch-action` for a static one — and say which you would prefer and why.

  • Why does the same touchmove handler cancel scrolling fine when attached to a div, but not when attached to document?
    The passive-by-default rule is scoped to four targets: `window`, `document`, `document.documentElement` and `document.body`. On any other element the listener is non-passive as it always was, so `preventDefault()` still works. Attaching to the document opts you into the default silently, which is why moving the same helper up the tree appears to break it.
  • Would you rather block a custom drag gesture with touch-action: none or with a non-passive touchmove listener?
    `touch-action: none` when the answer is static — a drawing surface or slider always owns its gestures. It is known before the gesture starts, so there is no main-thread round trip and pointer events are not cut short by `pointercancel`. Keep the non-passive listener for decisions that depend on runtime state, such as pull-to-refresh that blocks only at scroll position zero.
  • Is adding { passive: true } to every listener a good default for performance?
    No — it is meaningful only for scroll-blocking types (`touchstart`, `touchmove`, `wheel`, and `mousewheel`). On a `click` or `keydown` listener it changes nothing, since those never gate scrolling. Worse, applying it blindly to a handler that legitimately cancels silently breaks the cancellation with only a console warning to show for it.
  • How does a zoomable canvas tell a trackpad pinch apart from an ordinary scroll?
    Both arrive as `wheel` events, but a pinch is reported with `event.ctrlKey` set to true even though no key is held. Branch on that to zoom rather than pan. Also read `event.deltaMode` before trusting the delta values — it can be DOM_DELTA_LINE or DOM_DELTA_PAGE rather than pixels.

saying these in an interview costs you the question

  • Thinks preventDefault silently failed because of a browser bug
  • Believes every listener is passive by default now
  • Adds passive: true to handlers that need to cancel
  • Assumes wheel deltaY is always in pixels
  • Uses a non-passive document touchmove where touch-action would do

context