[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"verticals":3,"article-passive-event-listeners-scroll-performance":44,"search-suggestions":257,"path-membership-passive-event-listeners-scroll-performance":298,"related-passive-event-listeners-scroll-performance":299,"code:js:true:1237og8":425,"code:js:true:obx39d":426,"code:js:true:1w2flri":427,"code:js:true:hmu7ce":428,"comments-01a1972e-e328-75e5-bf48-38fd66ed3892":429},[4,20,32],{"id":5,"slug":6,"name":7,"tagline":8,"description":9,"accentFrom":10,"accentTo":11,"icon":12,"defaultLocale":13,"locales":14,"features":16,"position":19},"019fe637-3d33-714b-b57f-23e163ffca0c","dev","Web Development","Read it. Run it. Prove it.","A post a day on modern web development — most with an editable playground and a quiz that explains every answer. Free, no account needed.","violet-500","cyan-400","◇","en",[13,15],"fa",{"courses":17,"paths":17,"articles":17,"exams":18,"flashcards":18,"packages":17,"community":17,"certificates":17,"teams":17,"commerce":17},true,false,0,{"id":21,"slug":22,"name":23,"tagline":24,"description":25,"accentFrom":26,"accentTo":10,"icon":27,"defaultLocale":13,"locales":28,"features":30,"position":31},"019fe637-3dc2-754c-8657-0f175bfee7c6","lang","Languages","Learn a language the way you learn a codebase.","Grammar explained the way good documentation explains an API — one idea at a time, each with a quiz.","amber-400","⌘",[13,15,29],"es",{"courses":18,"paths":18,"articles":17,"exams":18,"flashcards":17,"packages":18,"community":17,"certificates":17,"teams":18,"commerce":18},2,{"id":33,"slug":34,"name":35,"tagline":36,"description":37,"accentFrom":38,"accentTo":39,"icon":40,"defaultLocale":13,"locales":41,"features":42,"position":43},"7b3c16f2-931d-410e-802e-e1fa4edab7de","soft","Soft Skills","The half of the job nobody wrote documentation for.","Weekly, on the parts of working life that decide more than your code does — first weeks, meetings, interviews, promotions, and the people around you. Written from what actually happens, and recorded as a podcast you can listen to on the walk.","emerald-400","teal-300","◉",[13],{"courses":18,"paths":18,"articles":17,"exams":18,"flashcards":18,"packages":18,"community":17,"certificates":18,"teams":18,"commerce":18},3,{"id":45,"slug":46,"title":47,"subtitle":48,"excerpt":49,"coverUrl":50,"locale":13,"readingMinutes":51,"publishedAt":52,"viewCount":53,"likeCount":19,"commentCount":19,"author":54,"vertical":59,"topic":60,"tags":63,"_count":75,"playground":77,"body":79,"bodyMd":240,"seo":241,"translationGroupId":244,"series":48,"podcastUrl":48,"verticalId":5,"thread":245,"assessments":247,"translations":253,"quiz":255},"01a1972e-e328-75e5-bf48-38fd66ed3892","passive-event-listeners-scroll-performance","You're Blocking Scroll by Default. `passive: true` Fixes It.",null,"Every touch and wheel listener you add can make the browser wait before it scrolls — because it has to check whether you'll call preventDefault(). `passive: true` tells it you won't, and scrolling stops waiting on your code.","\u002Fmedia\u002Fcovers\u002Fpassive-event-listeners-scroll-performance.png",5,"2026-11-06T06:10:55.120Z",34,{"id":55,"name":56,"username":57,"avatarUrl":48,"headline":58},"019fe637-3c25-7088-9034-39c9f15dc3c8","Parsa Jiravand","parsa","Frontend engineer · building bestpractic",{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":61,"name":62},"javascript","JavaScript",[64,66,69,72],{"slug":61,"name":65,"color":48},"Javascript",{"slug":67,"name":68,"color":48},"webdev","Webdev",{"slug":70,"name":71,"color":48},"performance","Performance",{"slug":73,"name":74,"color":48},"browser","Browser",{"assessments":76},1,{"slug":46,"title":78},"passive: true — interactive playground",{"blocks":80,"version":76},[81,85,88,93,96,102,105,109,112,115,118,122,125,128,131,134,137,140,143,146,149,152,155,159,162,165,168,172,175,178,182,185,188,191,194,197,200,203,206,209,212,215,222,225,228,231,234],{"id":82,"html":83,"type":84},"b1","\u003Cp>A teammate scrolled the product list on their phone and said it felt &quot;sticky&quot; — not broken, just a little behind, like the page was thinking about it before it moved. On a laptop trackpad it was perfectly smooth. Same code, same list, same 12-line scroll handler that did almost nothing: check \u003Ccode>scrollY\u003C\u002Fcode>, toggle a &quot;back to top&quot; button.\u003C\u002Fp>","paragraph",{"id":86,"html":87,"type":84},"b2","\u003Cp>Twelve lines. Nothing in it that should cost more than a millisecond. And yet, on a phone, every scroll felt like it had a half-beat of drag.\u003C\u002Fp>",{"id":89,"html":90,"text":91,"type":92,"level":31},"b3","The fix that didn&#39;t fix it","The fix that didn't fix it","heading",{"id":94,"html":95,"type":84},"b4","\u003Cp>The obvious read is &quot;the handler is too slow,&quot; so that&#39;s where the first hour went:\u003C\u002Fp>",{"id":97,"code":98,"type":99,"language":100,"highlight":101},"b5","list.addEventListener(\"scroll\", () => {\n  backToTop.hidden = list.scrollTop \u003C 400;\n});","code","js",[],{"id":103,"html":104,"type":84},"b6","\u003Cp>Wait — not even \u003Ccode>scroll\u003C\u002Fcode>. The actual listener was on \u003Ccode>touchmove\u003C\u002Fcode>, added so the button could react mid-gesture instead of waiting for the scroll to settle:\u003C\u002Fp>",{"id":106,"code":107,"type":99,"language":100,"highlight":108},"b7","list.addEventListener(\"touchmove\", () => {\n  backToTop.hidden = list.scrollTop \u003C 400;\n});",[],{"id":110,"html":111,"type":84},"b8","\u003Cp>That one line is the whole bug, and nothing in it looks dangerous. We debounced it. We memoized the scroll position. We moved the class toggle behind \u003Ccode>requestAnimationFrame\u003C\u002Fcode>. Chrome DevTools&#39; performance panel kept showing the same thing after every change: a yellow &quot;Rendering&quot; block sitting \u003Cem>before\u003C\u002Fem> the scroll could start, not after the handler ran.\u003C\u002Fp>",{"id":113,"html":114,"type":84},"b9","\u003Cp>\u003Cstrong>Before you keep reading — what do you think the browser was actually waiting for?\u003C\u002Fstrong>\u003C\u002Fp>",{"id":116,"html":117,"type":84},"b10","\u003Cp>It wasn&#39;t waiting for the handler to be fast. It was waiting to find out whether the handler would call \u003Ccode>preventDefault()\u003C\u002Fcode>.\u003C\u002Fp>",{"id":119,"html":120,"text":121,"type":92,"level":31},"b11","Why a \u003Ccode>touchmove\u003C\u002Fcode> listener can freeze a scroll that never gets cancelled","Why a touchmove listener can freeze a scroll that never gets cancelled",{"id":123,"html":124,"type":84},"b12","\u003Cp>\u003Ccode>touchmove\u003C\u002Fcode>, \u003Ccode>touchstart\u003C\u002Fcode>, and \u003Ccode>wheel\u003C\u002Fcode> are special. Any listener for one of them is allowed to call \u003Ccode>event.preventDefault()\u003C\u002Fcode> and cancel the browser&#39;s default action — in this case, cancel the scroll itself, mid-gesture. A custom image-zoom gesture, a swipe-to-dismiss card, a &quot;no, don&#39;t scroll past this point&quot; lock — all of those work by listening to one of these events and vetoing the browser&#39;s plan.\u003C\u002Fp>",{"id":126,"html":127,"type":84},"b13","\u003Cp>The browser doesn&#39;t know in advance that \u003Cem>your\u003C\u002Fem> handler isn&#39;t one of those. So by default it plays it safe: run the handler first, check \u003Ccode>event.defaultPrevented\u003C\u002Fcode>, and only \u003Cem>then\u003C\u002Fem> move the page. Your handler reads \u003Ccode>scrollY\u003C\u002Fcode> and sets \u003Ccode>.hidden\u003C\u002Fcode> — completely harmless — but the browser can&#39;t see that from the outside. It has to ask.\u003C\u002Fp>",{"id":129,"html":130,"type":84},"b14","\u003Cp>That ask is synchronous, and it happens on the main thread. If the main thread is busy with literally anything else at that instant — a re-render, a timer, a layout recalculation — the scroll waits behind it. Debouncing the handler doesn&#39;t help, because the delay isn&#39;t the handler&#39;s \u003Cem>duration\u003C\u002Fem>. It&#39;s the fact that scrolling is gated on the handler returning at all, every single time it fires.\u003C\u002Fp>",{"id":132,"html":133,"type":84},"b15","\u003Cp>This is exactly what Lighthouse is flagging when it tells you a page &quot;does not use passive listeners to improve scrolling performance.&quot; It isn&#39;t measuring your code&#39;s speed. It&#39;s measuring whether you&#39;ve told the browser it&#39;s allowed to stop asking.\u003C\u002Fp>",{"id":135,"html":136,"type":84},"b16","\u003C!-- playground:start -->",{"id":138,"html":139,"text":139,"type":92,"level":31},"b17","🎮 Try it yourself",{"id":141,"html":142,"type":84},"b18","\u003Cp>\u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fpassive-event-listeners-scroll-performance\u002Fplayground\">▶️ Open the interactive playground →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":144,"html":145,"type":84},"b19","\u003Cp>\u003Cem>Runs right in your browser — poke at it and watch the concept react live.\u003C\u002Fem>\u003C\u002Fp>",{"id":147,"html":148,"type":84},"b20","\u003C!-- playground:end -->",{"id":150,"html":151,"text":151,"type":92,"level":31},"b21","The one-line fix",{"id":153,"html":154,"type":84},"b22","\u003Cp>\u003Ccode>addEventListener\u003C\u002Fcode> takes a third argument beyond the usual boolean-for-capture — an options object with a \u003Ccode>passive\u003C\u002Fcode> flag:\u003C\u002Fp>",{"id":156,"code":157,"type":99,"language":100,"highlight":158},"b23","list.addEventListener(\"touchmove\", () => {\n  backToTop.hidden = list.scrollTop \u003C 400;\n}, { passive: true });",[],{"id":160,"html":161,"type":84},"b24","\u003Cp>\u003Ccode>{ passive: true }\u003C\u002Fcode> is a promise: \u003Cem>this listener will never call \u003Ccode>preventDefault()\u003C\u002Fcode>\u003C\u002Fem>. The browser takes you at your word, stops waiting, and starts scrolling the instant the gesture begins — on the compositor thread, independent of whatever your handler is doing. The handler still runs, still takes however long it takes, but scrolling no longer holds its breath for the answer.\u003C\u002Fp>",{"id":163,"html":164,"type":84},"b25","\u003Cp>Break that promise and call \u003Ccode>preventDefault()\u003C\u002Fcode> inside a passive listener anyway, and nothing happens — the call is silently ignored. Chrome and Firefox log a console warning telling you so; Safari doesn&#39;t, so don&#39;t count on the console to catch the mistake for you. There&#39;s no partial credit. A listener is either allowed to cancel the gesture, or it isn&#39;t, and \u003Ccode>passive\u003C\u002Fcode> is you choosing which.\u003C\u002Fp>",{"id":166,"html":167,"type":84},"b26","\u003Cp>\u003Cstrong>Try scrolling the list below with the toggle off, then on — same handler, same cost, very different feel.\u003C\u002Fstrong>\u003C\u002Fp>",{"id":169,"html":170,"text":171,"type":92,"level":31},"b27","&quot;Isn&#39;t this already the default?&quot;","\"Isn't this already the default?\"",{"id":173,"html":174,"type":84},"b28","\u003Cp>Sort of, and that&#39;s the trap. A few browsers quietly mark \u003Cem>some\u003C\u002Fem> touch listeners passive on their own — but only in specific cases (certain root-level targets, specific event types), and the exact cases differ by engine and have changed over time as each one shipped its own optimization. Relying on an undocumented default that varies by browser and by which element you attached to is not a strategy. It&#39;s a bet.\u003C\u002Fp>",{"id":176,"html":177,"type":84},"b29","\u003Cp>The one-line fix works everywhere, on every engine, whether or not that engine happens to be feeling generous today:\u003C\u002Fp>",{"id":179,"code":180,"type":99,"language":100,"highlight":181},"b30","list.addEventListener(\"touchmove\", handler, { passive: true });\nlist.addEventListener(\"wheel\", handler, { passive: true });",[],{"id":183,"html":184,"type":84},"b31","\u003Cp>Add it to every \u003Ccode>touchstart\u003C\u002Fcode>, \u003Ccode>touchmove\u003C\u002Fcode>, or \u003Ccode>wheel\u003C\u002Fcode> listener where you genuinely never call \u003Ccode>preventDefault()\u003C\u002Fcode> — scroll-position trackers, infinite-scroll triggers, analytics pings, &quot;show the nav on scroll up&quot; toggles, if you&#39;ve wired them to one of those three events. That&#39;s most of them. (A plain \u003Ccode>scroll\u003C\u002Fcode> listener doesn&#39;t need this at all — \u003Ccode>scroll\u003C\u002Fcode> events are never cancelable, so the browser was never waiting on them in the first place.) The only time you \u003Cem>can&#39;t\u003C\u002Fem> use \u003Ccode>{ passive: true }\u003C\u002Fcode> is when the listener sometimes needs to cancel the gesture — a custom swipe handler, a drag-to-reorder list, a pinch-zoom lock. In that case, leaving it non-passive is correct, not an oversight — you&#39;re keeping the veto because you actually use it.\u003C\u002Fp>",{"id":186,"html":187,"text":187,"type":92,"level":31},"b32","The list, scrolling again",{"id":189,"html":190,"type":84},"b33","\u003Cp>Back-to-top button, 12 lines, one options object added. The teammate who first noticed the stutter scrolled the same list on the same phone and didn&#39;t say anything — which, for a performance fix, is exactly the review you want. Nobody writes a bug report about something that just works.\u003C\u002Fp>",{"id":192,"html":193,"type":84},"b34","\u003Cp>Go check one scroll handler in your own codebase right now — does it call \u003Ccode>preventDefault()\u003C\u002Fcode> anywhere? If the honest answer is no, it&#39;s missing one argument. What&#39;s the oldest scroll listener in your code that&#39;s never had \u003Ccode>{ passive: true }\u003C\u002Fcode> added — and do you know whether it blocks anything?\u003C\u002Fp>",{"id":195,"html":196,"type":84},"b35","\u003C!-- quiz:start -->",{"id":198,"html":199,"text":199,"type":92,"level":31},"b36","🧠 Test yourself",{"id":201,"html":202,"type":84},"b37","\u003Cp>Think it clicked? \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fpassive-event-listeners-scroll-performance\u002Fquiz\">Take the 7-question quiz →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":204,"html":205,"type":84},"b38","\u003Cp>\u003Cem>Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.\u003C\u002Fem>\u003C\u002Fp>",{"id":207,"html":208,"type":84},"b39","\u003C!-- quiz:end -->",{"id":210,"html":211,"type":84},"b40","\u003C!-- related:start -->",{"id":213,"html":214,"text":214,"type":92,"level":31},"b41","📚 Read next",{"id":216,"type":217,"items":218,"ordered":18},"b42","list",[219,220,221],"\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fscrollend-event-native-scroll-stop-detection\">Stop Guessing When Scrolling Stops — Use scrollend\u003C\u002Fa>","\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fstorage-event-cross-tab-sync\">I Built Cross-Tab Logout. The storage Event Skipped One Tab.\u003C\u002Fa>","\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fcss-will-change-performance-hint\">You Added \u003Ccode>will-change\u003C\u002Fcode> to Fix the Jank. You Made It Worse.\u003C\u002Fa>",{"id":223,"html":224,"type":84},"b43","\u003C!-- related:end -->",{"id":226,"type":227},"b44","divider",{"id":229,"html":230,"type":84},"b45","\u003Cp>🚀 \u003Cstrong>Want more like this?\u003C\u002Fstrong> Every guide, playground, and quiz lives on \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002F\">bestpractic.org\u003C\u002Fa>\u003C\u002Fstrong> — open it and \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002F\">sign up free\u003C\u002Fa>\u003C\u002Fstrong> so the next one finds you.\u003C\u002Fp>",{"id":232,"html":233,"type":84},"b46","\u003Cp>\u003Cem>Thanks for reading! Let&#39;s stay connected:\u003C\u002Fem>\u003C\u002Fp>",{"id":235,"type":217,"items":236,"ordered":18},"b47",[237,238,239],"⭐ \u003Cstrong>GitHub\u003C\u002Fstrong> — follow me and star the projects: \u003Ca href=\"https:\u002F\u002Fgithub.com\u002Fparsajiravand\">github.com\u002Fparsajiravand\u003C\u002Fa>","💬 \u003Cstrong>Discord\u003C\u002Fstrong> — join the frontend best-practices community: \u003Ca href=\"https:\u002F\u002Fdiscord.gg\u002Fd9KRhuAwQ\">discord.gg\u002Fd9KRhuAwQ\u003C\u002Fa>","📸 \u003Cstrong>Instagram\u003C\u002Fstrong> — frontend best practices, daily: \u003Ca href=\"https:\u002F\u002Fwww.instagram.com\u002Fbestpractice___\u002F\">@bestpractice___\u003C\u002Fa>","A teammate scrolled the product list on their phone and said it felt \"sticky\" — not broken, just a little behind, like the page was thinking about it before it moved. On a laptop trackpad it was perfectly smooth. Same code, same list, same 12-line scroll handler that did almost nothing: check `scrollY`, toggle a \"back to top\" button.\n\nTwelve lines. Nothing in it that should cost more than a millisecond. And yet, on a phone, every scroll felt like it had a half-beat of drag.\n\n## The fix that didn't fix it\n\nThe obvious read is \"the handler is too slow,\" so that's where the first hour went:\n\n```js\nlist.addEventListener(\"scroll\", () => {\n  backToTop.hidden = list.scrollTop \u003C 400;\n});\n```\n\nWait — not even `scroll`. The actual listener was on `touchmove`, added so the button could react mid-gesture instead of waiting for the scroll to settle:\n\n```js\nlist.addEventListener(\"touchmove\", () => {\n  backToTop.hidden = list.scrollTop \u003C 400;\n});\n```\n\nThat one line is the whole bug, and nothing in it looks dangerous. We debounced it. We memoized the scroll position. We moved the class toggle behind `requestAnimationFrame`. Chrome DevTools' performance panel kept showing the same thing after every change: a yellow \"Rendering\" block sitting *before* the scroll could start, not after the handler ran.\n\n**Before you keep reading — what do you think the browser was actually waiting for?**\n\nIt wasn't waiting for the handler to be fast. It was waiting to find out whether the handler would call `preventDefault()`.\n\n## Why a `touchmove` listener can freeze a scroll that never gets cancelled\n\n`touchmove`, `touchstart`, and `wheel` are special. Any listener for one of them is allowed to call `event.preventDefault()` and cancel the browser's default action — in this case, cancel the scroll itself, mid-gesture. A custom image-zoom gesture, a swipe-to-dismiss card, a \"no, don't scroll past this point\" lock — all of those work by listening to one of these events and vetoing the browser's plan.\n\nThe browser doesn't know in advance that *your* handler isn't one of those. So by default it plays it safe: run the handler first, check `event.defaultPrevented`, and only *then* move the page. Your handler reads `scrollY` and sets `.hidden` — completely harmless — but the browser can't see that from the outside. It has to ask.\n\nThat ask is synchronous, and it happens on the main thread. If the main thread is busy with literally anything else at that instant — a re-render, a timer, a layout recalculation — the scroll waits behind it. Debouncing the handler doesn't help, because the delay isn't the handler's *duration*. It's the fact that scrolling is gated on the handler returning at all, every single time it fires.\n\nThis is exactly what Lighthouse is flagging when it tells you a page \"does not use passive listeners to improve scrolling performance.\" It isn't measuring your code's speed. It's measuring whether you've told the browser it's allowed to stop asking.\n\n\u003C!-- playground:start -->\n\n## 🎮 Try it yourself\n\n**[▶️ Open the interactive playground →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fpassive-event-listeners-scroll-performance\u002Fplayground)**\n\n_Runs right in your browser — poke at it and watch the concept react live._\n\n\u003C!-- playground:end -->\n\n## The one-line fix\n\n`addEventListener` takes a third argument beyond the usual boolean-for-capture — an options object with a `passive` flag:\n\n```js\nlist.addEventListener(\"touchmove\", () => {\n  backToTop.hidden = list.scrollTop \u003C 400;\n}, { passive: true });\n```\n\n`{ passive: true }` is a promise: *this listener will never call `preventDefault()`*. The browser takes you at your word, stops waiting, and starts scrolling the instant the gesture begins — on the compositor thread, independent of whatever your handler is doing. The handler still runs, still takes however long it takes, but scrolling no longer holds its breath for the answer.\n\nBreak that promise and call `preventDefault()` inside a passive listener anyway, and nothing happens — the call is silently ignored. Chrome and Firefox log a console warning telling you so; Safari doesn't, so don't count on the console to catch the mistake for you. There's no partial credit. A listener is either allowed to cancel the gesture, or it isn't, and `passive` is you choosing which.\n\n**Try scrolling the list below with the toggle off, then on — same handler, same cost, very different feel.**\n\n## \"Isn't this already the default?\"\n\nSort of, and that's the trap. A few browsers quietly mark *some* touch listeners passive on their own — but only in specific cases (certain root-level targets, specific event types), and the exact cases differ by engine and have changed over time as each one shipped its own optimization. Relying on an undocumented default that varies by browser and by which element you attached to is not a strategy. It's a bet.\n\nThe one-line fix works everywhere, on every engine, whether or not that engine happens to be feeling generous today:\n\n```js\nlist.addEventListener(\"touchmove\", handler, { passive: true });\nlist.addEventListener(\"wheel\", handler, { passive: true });\n```\n\nAdd it to every `touchstart`, `touchmove`, or `wheel` listener where you genuinely never call `preventDefault()` — scroll-position trackers, infinite-scroll triggers, analytics pings, \"show the nav on scroll up\" toggles, if you've wired them to one of those three events. That's most of them. (A plain `scroll` listener doesn't need this at all — `scroll` events are never cancelable, so the browser was never waiting on them in the first place.) The only time you *can't* use `{ passive: true }` is when the listener sometimes needs to cancel the gesture — a custom swipe handler, a drag-to-reorder list, a pinch-zoom lock. In that case, leaving it non-passive is correct, not an oversight — you're keeping the veto because you actually use it.\n\n## The list, scrolling again\n\nBack-to-top button, 12 lines, one options object added. The teammate who first noticed the stutter scrolled the same list on the same phone and didn't say anything — which, for a performance fix, is exactly the review you want. Nobody writes a bug report about something that just works.\n\nGo check one scroll handler in your own codebase right now — does it call `preventDefault()` anywhere? If the honest answer is no, it's missing one argument. What's the oldest scroll listener in your code that's never had `{ passive: true }` added — and do you know whether it blocks anything?\n\n\u003C!-- quiz:start -->\n\n## 🧠 Test yourself\n\nThink it clicked? **[Take the 7-question quiz →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fpassive-event-listeners-scroll-performance\u002Fquiz)**\n\n_Instant feedback, a hint on every question, and an explanation for each answer — right or wrong._\n\n\u003C!-- quiz:end -->\n\n\u003C!-- related:start -->\n\n## 📚 Read next\n\n- [Stop Guessing When Scrolling Stops — Use scrollend](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fscrollend-event-native-scroll-stop-detection)\n- [I Built Cross-Tab Logout. The storage Event Skipped One Tab.](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fstorage-event-cross-tab-sync)\n- [You Added `will-change` to Fix the Jank. You Made It Worse.](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fcss-will-change-performance-hint)\n\n\u003C!-- related:end -->\n\n---\n\n🚀 **Want more like this?** Every guide, playground, and quiz lives on **[bestpractic.org](https:\u002F\u002Fbestpractic.org\u002F)** — open it and **[sign up free](https:\u002F\u002Fbestpractic.org\u002F)** so the next one finds you.\n\n*Thanks for reading! Let's stay connected:*\n\n- ⭐ **GitHub** — follow me and star the projects: [github.com\u002Fparsajiravand](https:\u002F\u002Fgithub.com\u002Fparsajiravand)\n- 💬 **Discord** — join the frontend best-practices community: [discord.gg\u002Fd9KRhuAwQ](https:\u002F\u002Fdiscord.gg\u002Fd9KRhuAwQ)\n- 📸 **Instagram** — frontend best practices, daily: [@bestpractice___](https:\u002F\u002Fwww.instagram.com\u002Fbestpractice___\u002F)",{"title":47,"canonical":242,"description":243},"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fpassive-event-listeners-scroll-performance","Every touch and wheel listener you add can make the browser wait before it scrolls — because it has to check whether you'll call preventDefault(). `passive: true` tells it you won'","01a1972e-e328-75e5-bf48-3d8796e48de4",{"id":246,"locked":18},"01a1972e-e408-739e-9594-fc6b512d49b8",[248],{"id":249,"slug":46,"title":250,"_count":251},"01a1972e-e46f-7380-98ef-173f451d0425","Passive event listeners",{"questions":252},7,[254],{"locale":13,"slug":46},{"id":249,"slug":46,"title":250,"_count":256,"questionCount":252},{"questions":252},[258,260,262,266,270,274,276,280,284,286,290,294],{"slug":67,"name":68,"articles":259},169,{"slug":61,"name":65,"articles":261},136,{"slug":263,"name":264,"articles":265},"frontend","Frontend",94,{"slug":267,"name":268,"articles":269},"tutorial","Tutorial",69,{"slug":271,"name":272,"articles":273},"css","Css",50,{"slug":70,"name":71,"articles":275},29,{"slug":277,"name":278,"articles":279},"typescript","Typescript",25,{"slug":281,"name":282,"articles":283},"react","React",20,{"slug":73,"name":74,"articles":285},18,{"slug":287,"name":288,"articles":289},"programming","Programming",17,{"slug":291,"name":292,"articles":293},"node","Node",15,{"slug":295,"name":296,"articles":297},"html","Html",11,[],{"items":300,"meta":424},[301,313,331,350,368,387,406],{"id":45,"slug":46,"title":47,"subtitle":48,"excerpt":49,"coverUrl":50,"locale":13,"readingMinutes":51,"publishedAt":52,"viewCount":302,"likeCount":19,"commentCount":19,"author":303,"vertical":304,"topic":305,"tags":306,"_count":311,"playground":312,"hasQuiz":17,"hasPlayground":17},35,{"id":55,"name":56,"username":57,"avatarUrl":48,"headline":58},{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":61,"name":62},[307,308,309,310],{"slug":61,"name":65,"color":48},{"slug":67,"name":68,"color":48},{"slug":70,"name":71,"color":48},{"slug":73,"name":74,"color":48},{"assessments":76},{"slug":46},{"id":314,"slug":315,"title":316,"subtitle":48,"excerpt":317,"coverUrl":318,"locale":13,"readingMinutes":51,"publishedAt":319,"viewCount":320,"likeCount":19,"commentCount":19,"author":321,"vertical":322,"topic":323,"tags":324,"_count":329,"playground":330,"hasQuiz":17,"hasPlayground":17},"01a18b4e-fbba-7593-896a-8808e32d35cc","uint8array-tobase64","Stop Hacking Base64 With btoa(). toBase64() Fixes It Natively.","btoa() throws on emoji, and the classic String.fromCharCode(...bytes) workaround crashes on large files. toBase64() and fromBase64() on Uint8Array fix both — no string round trip, no library.","\u002Fmedia\u002Fcovers\u002Fuint8array-tobase64.png","2026-11-05T06:10:25.995Z",108,{"id":55,"name":56,"username":57,"avatarUrl":48,"headline":58},{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":61,"name":62},[325,326,327,328],{"slug":61,"name":65,"color":48},{"slug":67,"name":68,"color":48},{"slug":73,"name":74,"color":48},{"slug":267,"name":268,"color":48},{"assessments":76},{"slug":315},{"id":332,"slug":333,"title":334,"subtitle":48,"excerpt":335,"coverUrl":336,"locale":13,"readingMinutes":337,"publishedAt":338,"viewCount":339,"likeCount":19,"commentCount":19,"author":340,"vertical":341,"topic":342,"tags":343,"_count":348,"playground":349,"hasQuiz":17,"hasPlayground":17},"01a18647-e99b-71d4-a0db-6ad3f6778daf","getcurrentposition-not-a-promise","getCurrentPosition() Has No Default Timeout. That's the Trap.","A \"find my location\" button that works instantly on your laptop and hangs forever on a phone with a weak signal isn't flaky — it's getCurrentPosition() doing exactly what its defaults tell it to do. No timeout, no cached position, and a watcher nobody told to stop.","\u002Fmedia\u002Fcovers\u002Fgetcurrentposition-not-a-promise.png",6,"2026-11-04T06:10:25.023Z",138,{"id":55,"name":56,"username":57,"avatarUrl":48,"headline":58},{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":61,"name":62},[344,345,346,347],{"slug":61,"name":65,"color":48},{"slug":67,"name":68,"color":48},{"slug":287,"name":288,"color":48},{"slug":267,"name":268,"color":48},{"assessments":76},{"slug":333},{"id":351,"slug":352,"title":353,"subtitle":48,"excerpt":354,"coverUrl":355,"locale":13,"readingMinutes":337,"publishedAt":356,"viewCount":357,"likeCount":19,"commentCount":19,"author":358,"vertical":359,"topic":360,"tags":361,"_count":366,"playground":367,"hasQuiz":17,"hasPlayground":17},"01a18121-921f-706e-9714-f48280c4dc61","offscreencanvas-canvas-off-main-thread","Your Canvas Is Freezing the Page. OffscreenCanvas Fixes That.","Click a button while your canvas animation is running and the whole page stalls — because the pixels it draws, your layout, and your input handling all queue up on the same main thread. OffscreenCanvas moves the drawing itself into a worker, so the UI keeps responding no matter how much canvas work is happening.","\u002Fmedia\u002Fcovers\u002Foffscreencanvas-canvas-off-main-thread.png","2026-11-03T06:10:56.984Z",178,{"id":55,"name":56,"username":57,"avatarUrl":48,"headline":58},{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":61,"name":62},[362,363,364,365],{"slug":61,"name":65,"color":48},{"slug":67,"name":68,"color":48},{"slug":70,"name":71,"color":48},{"slug":263,"name":264,"color":48},{"assessments":76},{"slug":352},{"id":369,"slug":370,"title":371,"subtitle":48,"excerpt":372,"coverUrl":373,"locale":13,"readingMinutes":374,"publishedAt":375,"viewCount":376,"likeCount":19,"commentCount":19,"author":377,"vertical":378,"topic":379,"tags":380,"_count":385,"playground":386,"hasQuiz":17,"hasPlayground":17},"01a1623b-51e7-7669-89ad-eb54d67696eb","react-weekly-activity-component","React Activity Component: The State Between Render and Remount","React's Activity component hides a subtree without unmounting it — what state, effects, and renders actually do, plus a copy-paste cheat sheet.","\u002Fmedia\u002Fcovers\u002Freact-weekly-activity-component.png",14,"2026-11-02T06:10:18.569Z",248,{"id":55,"name":56,"username":57,"avatarUrl":48,"headline":58},{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":281,"name":282},[381,382,383,384],{"slug":281,"name":282,"color":48},{"slug":61,"name":65,"color":48},{"slug":267,"name":268,"color":48},{"slug":67,"name":68,"color":48},{"assessments":76},{"slug":370},{"id":388,"slug":389,"title":390,"subtitle":48,"excerpt":391,"coverUrl":392,"locale":13,"readingMinutes":337,"publishedAt":393,"viewCount":394,"likeCount":19,"commentCount":19,"author":395,"vertical":396,"topic":397,"tags":399,"_count":404,"playground":405,"hasQuiz":17,"hasPlayground":17},"01a17bfb-4a13-77fa-bb6c-5c654aec4dd7","scrollbar-gutter-layout-shift","I Deleted My Scroll-Lock Library's Ugly Hack. `scrollbar-gutter` Replaces It.","Opening a modal shifts your whole page sideways because the scrollbar vanishes with it — the bug every scroll-lock library papers over with a JS width measurement and a padding-right hack. `scrollbar-gutter: stable` fixes it in one line now that Safari finally caught up.","\u002Fmedia\u002Fcovers\u002Fscrollbar-gutter-layout-shift.png","2026-11-02T05:31:34.059Z",194,{"id":55,"name":56,"username":57,"avatarUrl":48,"headline":58},{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":271,"name":398},"CSS",[400,401,402,403],{"slug":271,"name":272,"color":48},{"slug":67,"name":68,"color":48},{"slug":263,"name":264,"color":48},{"slug":61,"name":65,"color":48},{"assessments":76},{"slug":389},{"id":407,"slug":408,"title":409,"subtitle":48,"excerpt":410,"coverUrl":411,"locale":13,"readingMinutes":337,"publishedAt":412,"viewCount":413,"likeCount":19,"commentCount":19,"author":414,"vertical":415,"topic":416,"tags":417,"_count":422,"playground":423,"hasQuiz":17,"hasPlayground":17},"01a152c8-59ef-7724-82e4-c6dc7fb6855f","pointer-events-unify-mouse-touch","You're Writing Mouse and Touch Handlers. Pointer Events Does Both.","A draggable card that works with mousedown\u002Fmousemove on desktop and breaks the moment you test it on a phone. The fix isn't a second set of touch listeners — it's deleting the mouse ones and switching to Pointer Events, which was built to handle mouse, touch, and pen as one input.","\u002Fmedia\u002Fcovers\u002Fpointer-events-unify-mouse-touch.png","2026-11-01T14:50:55.724Z",166,{"id":55,"name":56,"username":57,"avatarUrl":48,"headline":58},{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":61,"name":62},[418,419,420,421],{"slug":61,"name":65,"color":48},{"slug":67,"name":68,"color":48},{"slug":73,"name":74,"color":48},{"slug":263,"name":264,"color":48},{"assessments":76},{"slug":408},{"page":76,"perPage":252,"total":261,"totalPages":283},"\u003Cdiv class=\"shj shj-lang-js shj-multiline\" data-lang=\"js\">\u003Cdiv class=\"shj-scroll\">\u003Cdiv class=\"shj-numbers\">\u003Cdiv>1\u003C\u002Fdiv>\u003Cdiv>2\u003C\u002Fdiv>\u003Cdiv>3\u003C\u002Fdiv>\u003C\u002Fdiv>\u003Cdiv class=\"shj-code\">list\u003Cspan class=\"shj-oper\">.\u003C\u002Fspan>\u003Cspan class=\"shj-func\">addEventListener\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>\u003Cspan class=\"shj-str\">\"scroll\"\u003C\u002Fspan>\u003Cspan class=\"shj-oper\">,\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan> \u003Cspan class=\"shj-kwd\">=&gt;\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">{\u003C\u002Fspan>\n  backToTop\u003Cspan class=\"shj-oper\">.\u003C\u002Fspan>hidden \u003Cspan class=\"shj-oper\">=\u003C\u002Fspan> list\u003Cspan class=\"shj-oper\">.\u003C\u002Fspan>scrollTop \u003Cspan class=\"shj-oper\">&lt;\u003C\u002Fspan> \u003Cspan class=\"shj-num\">400\u003C\u002Fspan>;\n\u003Cspan class=\"shj-bracket\">}\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan>;\u003C\u002Fdiv>\u003C\u002Fdiv>\u003C\u002Fdiv>","\u003Cdiv class=\"shj shj-lang-js shj-multiline\" data-lang=\"js\">\u003Cdiv class=\"shj-scroll\">\u003Cdiv class=\"shj-numbers\">\u003Cdiv>1\u003C\u002Fdiv>\u003Cdiv>2\u003C\u002Fdiv>\u003Cdiv>3\u003C\u002Fdiv>\u003C\u002Fdiv>\u003Cdiv class=\"shj-code\">list\u003Cspan class=\"shj-oper\">.\u003C\u002Fspan>\u003Cspan class=\"shj-func\">addEventListener\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>\u003Cspan class=\"shj-str\">\"touchmove\"\u003C\u002Fspan>\u003Cspan class=\"shj-oper\">,\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan> \u003Cspan class=\"shj-kwd\">=&gt;\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">{\u003C\u002Fspan>\n  backToTop\u003Cspan class=\"shj-oper\">.\u003C\u002Fspan>hidden \u003Cspan class=\"shj-oper\">=\u003C\u002Fspan> list\u003Cspan class=\"shj-oper\">.\u003C\u002Fspan>scrollTop \u003Cspan class=\"shj-oper\">&lt;\u003C\u002Fspan> \u003Cspan class=\"shj-num\">400\u003C\u002Fspan>;\n\u003Cspan class=\"shj-bracket\">}\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan>;\u003C\u002Fdiv>\u003C\u002Fdiv>\u003C\u002Fdiv>","\u003Cdiv class=\"shj shj-lang-js shj-multiline\" data-lang=\"js\">\u003Cdiv class=\"shj-scroll\">\u003Cdiv class=\"shj-numbers\">\u003Cdiv>1\u003C\u002Fdiv>\u003Cdiv>2\u003C\u002Fdiv>\u003Cdiv>3\u003C\u002Fdiv>\u003C\u002Fdiv>\u003Cdiv class=\"shj-code\">list\u003Cspan class=\"shj-oper\">.\u003C\u002Fspan>\u003Cspan class=\"shj-func\">addEventListener\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>\u003Cspan class=\"shj-str\">\"touchmove\"\u003C\u002Fspan>\u003Cspan class=\"shj-oper\">,\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan> \u003Cspan class=\"shj-kwd\">=&gt;\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">{\u003C\u002Fspan>\n  backToTop\u003Cspan class=\"shj-oper\">.\u003C\u002Fspan>hidden \u003Cspan class=\"shj-oper\">=\u003C\u002Fspan> list\u003Cspan class=\"shj-oper\">.\u003C\u002Fspan>scrollTop \u003Cspan class=\"shj-oper\">&lt;\u003C\u002Fspan> \u003Cspan class=\"shj-num\">400\u003C\u002Fspan>;\n\u003Cspan class=\"shj-bracket\">}\u003C\u002Fspan>\u003Cspan class=\"shj-oper\">,\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">{\u003C\u002Fspan> passive\u003Cspan class=\"shj-oper\">:\u003C\u002Fspan> \u003Cspan class=\"shj-bool\">true\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">}\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan>;\u003C\u002Fdiv>\u003C\u002Fdiv>\u003C\u002Fdiv>","\u003Cdiv class=\"shj shj-lang-js shj-multiline\" data-lang=\"js\">\u003Cdiv class=\"shj-scroll\">\u003Cdiv class=\"shj-numbers\">\u003Cdiv>1\u003C\u002Fdiv>\u003Cdiv>2\u003C\u002Fdiv>\u003C\u002Fdiv>\u003Cdiv class=\"shj-code\">list\u003Cspan class=\"shj-oper\">.\u003C\u002Fspan>\u003Cspan class=\"shj-func\">addEventListener\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>\u003Cspan class=\"shj-str\">\"touchmove\"\u003C\u002Fspan>\u003Cspan class=\"shj-oper\">,\u003C\u002Fspan> handler\u003Cspan class=\"shj-oper\">,\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">{\u003C\u002Fspan> passive\u003Cspan class=\"shj-oper\">:\u003C\u002Fspan> \u003Cspan class=\"shj-bool\">true\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">}\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan>;\nlist\u003Cspan class=\"shj-oper\">.\u003C\u002Fspan>\u003Cspan class=\"shj-func\">addEventListener\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>\u003Cspan class=\"shj-str\">\"wheel\"\u003C\u002Fspan>\u003Cspan class=\"shj-oper\">,\u003C\u002Fspan> handler\u003Cspan class=\"shj-oper\">,\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">{\u003C\u002Fspan> passive\u003Cspan class=\"shj-oper\">:\u003C\u002Fspan> \u003Cspan class=\"shj-bool\">true\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">}\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan>;\u003C\u002Fdiv>\u003C\u002Fdiv>\u003C\u002Fdiv>",{"locked":18,"total":19,"comments":430},[]]