Articles

Passive Event Listeners: Smoother Scrolling in JavaScript

Passive event listeners tell the browser a handler won't call preventDefault(), so scrolling starts at once. How { passive: true } works and when to use it.

Takina Takina · · 5 min read
A laptop screen showing code in blue light

A passive event listener is an event handler registered with { passive: true }, which promises the browser that the handler will never call preventDefault(). Because the browser knows the default action can’t be cancelled, it can start scrolling or zooming immediately instead of waiting for your JavaScript to finish. For touch and wheel events, that one flag is often the difference between scrolling that tracks your finger and scrolling that stutters.

The problem passive listeners solve

When a user touches the screen or spins a mouse wheel, the browser wants to scroll. Scrolling in modern browsers is typically handled off the main thread by the compositor, which can move layers around smoothly even when JavaScript is busy.

There’s a catch. A touchstart, touchmove, or wheel handler is allowed to call event.preventDefault(), which cancels the scroll. So if any such listener is registered on the element being scrolled or one of its ancestors, the browser can’t safely scroll until it has run that listener and confirmed nobody cancelled the event.

That means scrolling is now blocked on the main thread. If the main thread is busy parsing a script, running a long task, or handling another event, which the event loop will process one at a time, the scroll waits. The page feels sticky, and the delay is especially noticeable on lower-end phones.

The frustrating part is that most of these handlers never call preventDefault(). Analytics, gesture tracking, and lazy-loading code often listen to touch or wheel events just to observe them. The browser still has to wait, because it can’t know that in advance.

How { passive: true } works

The third argument to addEventListener can be an options object. Setting passive: true makes a promise:

window.addEventListener(
  "touchmove",
  (event) => {
    trackGesture(event.touches[0]);
  },
  { passive: true }
);

With this promise in place, the browser can begin scrolling right away and run your handler alongside it. Your handler still fires and still receives the event; it just can’t cancel the default action.

If a passive listener does call preventDefault(), the call is ignored and browsers log a console warning. Nothing breaks, but the cancellation you wanted doesn’t happen.

Which events it matters for

Passive listeners only make a difference for events whose default action is something the browser wants to do quickly and that a listener could cancel:

  • touchstart and touchmove, which can start or continue a scroll or pinch-zoom
  • wheel, which scrolls on mouse and trackpad input
  • mousewheel, the older non-standard wheel event

For most other events the flag has no practical effect. The scroll event in particular is fired after scrolling has already happened and can’t be cancelled, so marking it passive changes nothing. Click and keyboard handlers aren’t on the scrolling critical path either.

Browser defaults you should know about

Because so many sites registered touch and wheel listeners without needing cancellation, browsers changed the default. Touch and wheel listeners added to window-level targets, window, document, and document.body, are treated as passive unless you explicitly set passive: false.

That intervention fixed a lot of sites at once, but it has a side effect: code that relied on calling preventDefault() in a document-level touchmove handler to stop scrolling silently stopped working. If you need cancellation on those targets, you must now opt in:

document.addEventListener("touchmove", onDrag, { passive: false });

Listeners on ordinary elements are not passive by default, so it’s still worth being explicit there.

When you need passive: false

Some interactions genuinely need to block the browser’s default behavior:

  • A custom drag-and-drop or drawing surface where touch movement should move an object, not the page
  • A carousel or map that implements its own panning
  • A custom zoom gesture that should replace browser pinch-zoom

In those cases, register the listener with passive: false on the smallest element that needs it, not on the whole document. That keeps the main-thread wait confined to touches that start inside that element, and lets the rest of the page scroll freely.

Better still, check whether CSS can express the behavior. The touch-action property tells the browser which gestures an element handles natively, for example touch-action: none on a drawing canvas or touch-action: pan-y on a horizontal swiper. The browser learns this from styles before any input arrives, with no JavaScript in the loop. Similarly, overscroll-behavior can stop scroll chaining and pull-to-refresh without a single preventDefault() call.

Passive vs non-passive listeners

Passive listenerNon-passive listener
Can call preventDefault()No, the call is ignoredYes
Blocks scrolling on the main threadNoYes, until the handler finishes
Default for touch and wheel on window, document, bodyYesOnly with passive: false
Good forTracking, analytics, observationCustom gestures, drag, canvas input

Feature detection for old browsers

Very old browsers interpreted the third argument only as a boolean useCapture, so passing an object would be read as true. If you still support such environments, the standard detection trick uses a getter:

let supportsPassive = false;
try {
  const opts = Object.defineProperty({}, "passive", {
    get() { supportsPassive = true; return false; }
  });
  window.addEventListener("test", null, opts);
  window.removeEventListener("test", null, opts);
} catch {}

For any current browser, you can pass the options object directly.

How it connects to responsiveness metrics

Scroll jank is a visible symptom of main-thread contention, the same contention that drives poor Interaction to Next Paint scores. Passive listeners don’t make your handlers faster; they take scrolling out of the waiting line. Keep the handlers themselves light too: throttle expensive work, batch visual updates into requestAnimationFrame, and use observers instead of scroll math where possible. Our Core Web Vitals guide covers the wider picture.

If you attach one listener high in the tree and dispatch by target, as in event delegation, be deliberate about whether that root listener needs to cancel anything. A single non-passive touch listener on the document makes every touch on the page wait.

The takeaway

Passive event listeners tell the browser your touch or wheel handler won’t cancel the default action, so scrolling can start immediately instead of waiting on the main thread. Use { passive: true } for any listener that only observes, reserve passive: false for the specific elements that implement custom gestures, and reach for CSS touch-action and overscroll-behavior before writing JavaScript to block scrolling at all.

Takina Takina · · 5 min read

Code Splitting Explained: Smaller JavaScript Bundles

Code splitting breaks one large JavaScript bundle into smaller chunks loaded on demand. How bundlers do it, the main strategies, and the trade-offs.

#JavaScript #Performance #Frontend
Takina Takina · · 4 min read

What Is requestAnimationFrame? Smooth JS Animation

requestAnimationFrame schedules a callback right before the browser repaints, syncing JavaScript animation to the display's refresh rate. How it works.

#JavaScript #Frontend #Performance
Takina Takina · · 5 min read

Debounce vs Throttle: Rate-Limiting Events in JS

Debounce and throttle both tame rapid-fire events, but differently — debounce waits for a pause, throttle enforces a steady rate. When to use each.

#JavaScript #Frontend #Performance