Passive Event Listeners in 2026: Fix Scroll Jank From Third-Party Scripts
A passive event listener promises the browser you won't call preventDefault(), so the compositor thread can scroll immediately without waiting for JavaScript. Here's how I audit and fix the third-party offenders that still ship blocking touchstart and wheel handlers in 2026.
A passive event listener is an addEventListener registration where you pass { passive: true } as the third argument, promising the browser your handler will never call event.preventDefault(). That promise lets the compositor thread scroll the page immediately without waiting for JavaScript to run, and honestly, it's the single most effective fix I know for touch and wheel scroll jank in 2026. On the large e-commerce site I work on, one non-passive touchstart handler injected by a marketing tag was costing us 180 ms of interaction delay on mid-range Android. This is the article I wish that vendor's team had read first.
Chrome, Safari and Firefox have all treated document-level touchstart, touchmove, wheel and mousewheel listeners as passive-by-default since 2018, but element-level and window-level listeners are still non-passive unless you opt in.
Non-passive listeners on scroll-blocking events force the browser to run your JavaScript before scrolling a single pixel, which shows up as scroll jank and inflated INP.
The Chrome DevTools Performance panel flags "Scroll-blocking touch event handlers" and Lighthouse surfaces the same warning under "Uses passive listeners to improve scrolling performance".
Third-party analytics and chat widgets are the top offenders in 2026. Segment, Hotjar, Intercom and older Google Tag Manager containers are the names I see most often.
The CSS touch-action property is the correct way to disable native scroll behaviour; do not use a non-passive touchstart handler for that purpose.
Passive listeners do not slow down preventDefault()-based logic. They simply move it out of the scroll-blocking path.
What is a passive event listener?
A passive event listener is an event handler registered with { passive: true } as the options argument to addEventListener. Passing that flag tells the browser two things simultaneously: your handler will never call event.preventDefault(), and the browser is therefore free to run scroll and zoom work on the compositor thread without waiting for your JavaScript to finish. The flag was standardised in the DOM specification in 2016 specifically to fix scroll jank, and it's now supported in every browser I have shipped for in the last five years.
The mechanics matter. When a user drags a finger down a product listing, the browser fires touchstart, then a stream of touchmove events. If any registered listener could call preventDefault(), the compositor thread has to freeze the pixels on screen, dispatch the event to the main thread, wait for JavaScript to decide, and only then move the page. A passive listener short-circuits that round trip. The compositor scrolls immediately and the JavaScript still runs, just not in the critical path. On a 60 Hz display, that's the difference between a smooth drag and a visible stutter.
Why non-passive touch and wheel listeners cause jank
Scroll jank from non-passive listeners is a compositor problem, not a main-thread work problem. Even if your handler is empty (say, () => {}), the browser still has to check whether it called preventDefault(). That check happens on the main thread. If the main thread is busy running a bundle parse, a React render, or a third-party analytics tag, the browser cannot know whether the scroll is cancelled, so it holds the frame. The pixels do not move until the main thread yields.
The consequence is a class of jank that does not show up in a normal Lighthouse audit unless you scroll during the trace. I've chased regressions where LCP looked perfect, CLS was zero, and yet the product listing page felt broken on real phones. The culprit was always the same: a script had attached a non-passive touchstart listener to document or window, and the main thread was too busy to acknowledge the scroll fast enough.
You can see the effect directly in the Chrome DevTools Performance panel. Record an interaction that starts with a touch, and any frame where a non-passive listener delayed the scroll shows a red-flagged "Scroll-blocking event handler" warning under the Interactions track. In my experience, roughly two-thirds of these warnings on production e-commerce sites trace back to code the site owner didn't write.
Which listeners are passive by default in 2026?
Since Chrome 56 in 2017, listeners on document, document.body, document.documentElement and window for touchstart, touchmove, wheel and mousewheel events are passive by default. Safari and Firefox followed with the same behaviour by 2018. If you register a listener on one of those roots without options, you get passive treatment. Even if your code calls preventDefault(), the call is ignored and DevTools logs a console warning.
The trap is that this default only applies to those exact scroll-root nodes. If you attach the same listener to a specific <div>, an image carousel, or any element that is not the document root, the listener is non-passive by default. That's where most modern jank comes from: component libraries, image galleries and modal implementations that bind to their own container element and forget to set the flag.
How to audit your site for non-passive listeners
You need two audit passes: a static one for your own code, and a runtime one for third-party scripts you cannot easily grep. I run both on every release.
Static audit: grep the bundle
For your own source, ripgrep will find the offenders in seconds:
Then flag every match that isn't immediately followed by { passive: true } or { passive: false }. The explicit false form is fine (someone thought about it), but a missing options argument is a bug waiting to happen if the listener target ever changes from window to a component.
Runtime audit: monkey-patch addEventListener
For third-party scripts, static analysis is useless because the code arrives at runtime. I wrap addEventListener before any other script runs and log every non-passive scroll-blocking registration:
// Paste this as the FIRST inline script in <head>
(function () {
const BLOCKING = new Set(['touchstart', 'touchmove', 'wheel', 'mousewheel']);
const original = EventTarget.prototype.addEventListener;
EventTarget.prototype.addEventListener = function (type, listener, opts) {
if (BLOCKING.has(type)) {
const passive = typeof opts === 'object' && opts !== null && opts.passive === true;
if (!passive) {
console.warn('[perf] non-passive', type, 'on', this, 'from', new Error().stack);
}
}
return original.call(this, type, listener, opts);
};
})();
The new Error().stack trick is the important part. It gives you a source URL for the offending script, which is how I identify which vendor to email. I keep this snippet permanently enabled on our staging environment and pipe the warnings to our RUM backend using the same pattern I describe in my real user monitoring web-vitals setup.
Lighthouse and PageSpeed Insights
Both tools include a "Uses passive listeners to improve scrolling performance" audit that surfaces the same information without any custom code. It's a good CI gate, but be aware that the audit only catches listeners that fired during the trace. A lazy-loaded chat widget that never opens will not be flagged.
Third-party scripts that ship blocking scroll handlers
This is the section I care about most, because it's where my week actually goes. Here are the vendors I've caught shipping non-passive scroll-blocking listeners on production sites in the last twelve months, and what I did about each.
Vendor
Event
Where it binds
Fix I shipped
Older Google Tag Manager (pre-2024) containers
scroll trigger
window
Upgraded container, moved to server-side tagging
Hotjar heatmaps
touchstart, touchmove
document
Deferred load until requestIdleCallback, contacted vendor
Intercom messenger (legacy)
wheel
messenger iframe container
Upgraded to 2025+ SDK which uses passive listeners
Segment analytics.js v1
touchstart
document.body
Migrated to analytics.js v2 (Node-style bundler)
Older carousel libraries
touchstart, touchmove
slider element
Replaced with CSS scroll-snap and native scroll
The pattern is depressingly consistent. Tags written before 2018 default to non-passive because that was the browser default at the time, and vendors are slow to update their embed snippets even when the SDK itself is fixed. My rule of thumb: any third-party script older than five years should be assumed guilty until proven passive.
For a full workflow on quarantining these tags (sandbox iframes, Partytown and web-worker-based tag isolation, and consent-gated loading), I go deeper in my third-party scripts guide.
How to fix "non-passive event listener" warnings
The fix depends on why the listener exists in the first place. Ask yourself: does the handler ever call preventDefault()? If the honest answer is "no, but it might one day", you should still mark it passive today and add a comment explaining that changing the flag requires a compositor-impact review.
Case 1: handler does not call preventDefault
This is 90 % of the wild cases. Add the flag:
// Before
carousel.addEventListener('touchstart', trackSwipe);
// After
carousel.addEventListener('touchstart', trackSwipe, { passive: true });
If you have to support Internet Explorer 11 or Android WebView older than the 2018 vintage, use feature detection to avoid throwing on the options object. In practice nobody I ship for still supports those, but for a library the pattern is:
Case 2: handler conditionally calls preventDefault
If your handler only cancels scrolling in a specific mode (a drawing canvas, a drag-and-drop reorder), split the listener. Register a passive listener that runs unconditionally, and only register the non-passive one when the mode activates:
Explicit passive: false is honest. It silences the linter warning and documents intent. What you must not do is leave the options argument off and assume the default; that default varies by target node and has changed twice in the last decade.
How passive listeners affect INP and long animation frames
Passive listeners do not directly change Interaction to Next Paint, because INP measures pointer, tap, keyboard and click interactions, not scroll. But there's a strong indirect effect: a blocked scroll frame forces the main thread to process the scroll event synchronously, which shows up as a long animation frame that pushes out every other interaction that happens to land in the same frame.
In the Long Animation Frames API data I collect, roughly 8 % of our tail-latency LoAF entries on mobile have a scroll-blocking event handler in the scripts attribution list. Marking those handlers passive typically removes the entry entirely. Either the frame falls under the 50 ms LoAF threshold, or the scroll simply is not attributed to the main thread any more because the compositor handled it.
The measurable pattern I look for in RUM data: a spike in event-timing entries where processingStart - startTime is large (input delay) but processingEnd - processingStart is tiny. That gap is the browser waiting for the main thread to acknowledge the event, and it's exactly what passive listeners eliminate.
Use touch-action CSS instead of preventDefault
Nine times out of ten, when I ask a developer why their touchstart handler calls preventDefault(), the answer is "to stop the page scrolling when the user swipes this carousel horizontally". The correct 2026 solution is the CSS touch-action property, not a non-passive listener:
touch-action is resolved on the compositor thread before any JavaScript runs, so it prevents scrolling without any of the round-trip cost. The MDN reference for touch-action lists every accepted value; pan-x, pan-y, pinch-zoom and none cover almost every real case I've seen.
Once you set touch-action, you can drop the preventDefault() call entirely and mark the listener passive. The behaviour is identical for users, but the compositor never blocks.
When you should not use passive: true
The one case where passive listeners are actively wrong is when your JavaScript is the source of truth for whether scrolling should happen at all. Think canvas-based drawing apps, a game with custom camera controls, or a pull-to-refresh implementation that hasn't been rewritten to use the overscroll-behavior CSS property. In those cases preventDefault() is load-bearing, and the listener must be non-passive.
Even then, scope the non-passive listener as narrowly as possible. Bind to the specific canvas element, not the document. Register it only when the interactive mode is entered and remove it on exit. Every extra millisecond a non-passive listener is registered on a broad element is a millisecond the compositor might have to wait.
Frequently Asked Questions
Should I use passive: true for every event listener?
Only for events the browser treats as scroll-blocking: touchstart, touchmove, wheel and mousewheel. For click, keydown, submit and everything else, the passive flag has no observable effect because those events do not participate in scrolling. Setting it there just adds noise.
Does passive: true make my code faster?
Your JavaScript runs at the same speed either way. What passive listeners speed up is the browser's compositor thread, which can now scroll pixels without waiting for your main thread. The user sees smoother scrolling; your handler still runs.
Why does calling preventDefault in a passive listener throw a warning?
You promised the browser you would not cancel the event, so the call is ignored. Chrome and Firefox log a console warning to help you find the mistake. Either remove the preventDefault() call or set passive: false explicitly.
Are scroll event listeners passive by default in 2026?
The plain scroll event isn't cancelable at all, so passive versus non-passive is meaningless for it. The scroll-blocking events are touchstart, touchmove, wheel and mousewheel, and only those default to passive when attached to document, document.body, document.documentElement or window.
How do I tell which script added a non-passive listener?
Monkey-patch addEventListener in the first inline script of your <head> and log new Error().stack whenever a scroll-blocking event is registered without passive: true. The stack trace names the offending file. See the runtime audit snippet above for the exact code.
Learn how the No-Vary-Search response header lets Chrome 141 reuse prefetches and disk cache entries across utm_ and other query parameters, with copy-paste recipes and debugging tips.
The fetchLater() API queues an HTTP request that the browser fires on bfcache entry, page hide, or a timeout, plugging the reliability gaps that make sendBeacon drop 5-15% of your RUM data on mobile.
The PerformanceObserver API is how you subscribe to Core Web Vitals, resource timings, long tasks, and layout shifts in the browser. Here is every entry type, buffered replay, and a production-ready RUM pipeline.