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.
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:
touchstartandtouchmove, which can start or continue a scroll or pinch-zoomwheel, which scrolls on mouse and trackpad inputmousewheel, 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 listener | Non-passive listener | |
|---|---|---|
Can call preventDefault() | No, the call is ignored | Yes |
| Blocks scrolling on the main thread | No | Yes, until the handler finishes |
| Default for touch and wheel on window, document, body | Yes | Only with passive: false |
| Good for | Tracking, analytics, observation | Custom 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.
Tagged
Keep reading
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.
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.
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.