[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"verticals":3,"quiz-scrollend-event-native-scroll-stop-detection":44,"search-suggestions":60,"quiz-article-scrollend-event-native-scroll-stop-detection":108},[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,"kind":47,"title":48,"description":49,"config":50,"verticalId":5,"vertical":55,"course":52,"_count":56,"access":57,"attempts":59,"questionCount":51},"01a0ec73-0eda-73bc-843e-310ad0ccea78","scrollend-event-native-scroll-stop-detection","PRACTICE_QUIZ","scrollend: has scrolling actually stopped?","Eight questions on why debouncing the scroll event was always a guess, what scrollend actually fires on, the loop it can cause, and where its browser support really stands.",{"questionCount":51,"timeLimitSec":52,"shuffleQuestions":18,"shuffleOptions":17,"negativeMarking":19,"passScorePct":53,"maxAttempts":52,"revealAnswers":54,"allowFlagging":18,"allowBacktracking":17},8,null,70,"AFTER_SUBMIT",{"slug":6,"name":7},{"questions":51},{"allowed":17,"reason":58},"FREE",[],[61,65,69,73,77,81,85,89,93,97,101,104],{"slug":62,"name":63,"articles":64},"webdev","Webdev",114,{"slug":66,"name":67,"articles":68},"javascript","Javascript",96,{"slug":70,"name":71,"articles":72},"frontend","Frontend",75,{"slug":74,"name":75,"articles":76},"tutorial","Tutorial",41,{"slug":78,"name":79,"articles":80},"css","Css",36,{"slug":82,"name":83,"articles":84},"typescript","Typescript",17,{"slug":86,"name":87,"articles":88},"performance","Performance",14,{"slug":90,"name":91,"articles":92},"react","React",13,{"slug":94,"name":95,"articles":96},"browser","Browser",11,{"slug":98,"name":99,"articles":100},"node","Node",10,{"slug":102,"name":103,"articles":51},"html","Html",{"slug":105,"name":106,"articles":107},"accessibility","Accessibility",7,{"id":109,"slug":46,"title":110,"subtitle":52,"excerpt":111,"coverUrl":112,"locale":13,"readingMinutes":113,"publishedAt":114,"viewCount":115,"likeCount":19,"commentCount":19,"author":116,"vertical":121,"topic":122,"tags":124,"_count":131,"playground":133,"body":135,"bodyMd":292,"seo":293,"translationGroupId":296,"series":52,"podcastUrl":52,"verticalId":5,"thread":297,"assessments":299,"translations":302,"quiz":304},"01a0ec73-0e91-7572-8a40-07168efe8bbf","Stop Guessing When Scrolling Stops — Use scrollend","For years, 'has the user stopped scrolling?' was a guess: debounce the scroll event, pick a delay, hope. The scrollend event answers the question directly — and as of Safari 26.2, it finally works everywhere. Here's the bug the guess causes, and the one-line fix.","\u002Fmedia\u002Fcovers\u002Fscrollend-event-native-scroll-stop-detection.png",5,"2026-09-29T09:16:39.108Z",71,{"id":117,"name":118,"username":119,"avatarUrl":52,"headline":120},"019fe637-3c25-7088-9034-39c9f15dc3c8","Parsa Jiravand","parsa","Frontend engineer · building bestpractic",{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":66,"name":123},"JavaScript",[125,126,127,130],{"slug":66,"name":67,"color":52},{"slug":62,"name":63,"color":52},{"slug":128,"name":129,"color":52},"dom","Dom",{"slug":70,"name":71,"color":52},{"assessments":132},1,{"slug":46,"title":134},"scrollend vs. the debounce guess — interactive playground",{"blocks":136,"version":132},[137,141,144,147,150,155,158,164,167,170,173,176,180,183,186,189,192,195,198,201,204,207,211,214,218,221,224,227,230,234,237,240,243,246,249,252,255,258,261,264,267,274,277,280,283,286],{"id":138,"html":139,"type":140},"b1","\u003Cp>You&#39;ve built the &quot;load more&quot; trigger for an infinite-scroll feed a dozen times. The rule is always the same: don&#39;t fire the fetch on every \u003Ccode>scroll\u003C\u002Fcode> event — that&#39;s dozens of calls a second — fire it once, after the user actually stops.\u003C\u002Fp>","paragraph",{"id":142,"html":143,"type":140},"b2","\u003Cp>So you debounce it. Wait 150ms after the last \u003Ccode>scroll\u003C\u002Fcode> event, then treat that as &quot;stopped,&quot; and fetch the next page.\u003C\u002Fp>",{"id":145,"html":146,"type":140},"b3","\u003Cp>It ships fine. Then someone scrolls with a trackpad, pauses for a beat with their fingers still down, and your &quot;stopped&quot; check fires mid-gesture. The fetch goes out, the list jumps, the user loses their place — while they&#39;re still scrolling.\u003C\u002Fp>",{"id":148,"html":149,"type":140},"b4","\u003Cp>The bug isn&#39;t your debounce delay. It&#39;s that \u003Ccode>scroll\u003C\u002Fcode> never tells you when scrolling is actually over. You&#39;ve been asking it a question it was never built to answer.\u003C\u002Fp>",{"id":151,"html":152,"text":153,"type":154,"level":31},"b5","The obvious fix, and why it&#39;s a guess","The obvious fix, and why it's a guess","heading",{"id":156,"html":157,"type":140},"b6","\u003Cp>Here&#39;s the version most of us have shipped:\u003C\u002Fp>",{"id":159,"code":160,"type":161,"language":162,"highlight":163},"b7","let timer;\ncontainer.addEventListener(\"scroll\", () => {\n  clearTimeout(timer);\n  timer = setTimeout(() => {\n    console.log(\"probably stopped\");\n    loadNextPage();\n  }, 150);\n});","code","js",[],{"id":165,"html":166,"type":140},"b8","\u003Cp>This works \u003Cem>most\u003C\u002Fem> of the time, because most scrolling really does leave gaps longer than 150ms once it&#39;s done. But the delay is a bet, not a fact. Set it too low and any brief pause — a finger resting on the trackpad or screen mid-gesture, a slow drag — trips your timer before the user is actually done. Set it too high and every genuinely-finished scroll sits there for an extra beat before your code reacts, which reads as lag on a &quot;back to top&quot; button or a scroll-spy nav highlight.\u003C\u002Fp>",{"id":168,"html":169,"type":140},"b9","\u003Cp>There was never a version of this number that was simply \u003Cem>correct\u003C\u002Fem>. It couldn&#39;t be — the browser is the only thing that actually knows when a scroll gesture and its momentum are done, and it wasn&#39;t telling you.\u003C\u002Fp>",{"id":171,"html":172,"text":172,"type":154,"level":31},"b10","The event that answers the actual question",{"id":174,"html":175,"type":140},"b11","\u003Cp>It does now. \u003Ccode>scrollend\u003C\u002Fcode> fires once, on the same target you&#39;d attach \u003Ccode>scroll\u003C\u002Fcode> to, exactly when the browser considers the scroll position to have finished changing — the user&#39;s gesture has ended \u003Cem>and\u003C\u002Fem> there are no more pending position updates (including the tail end of momentum scrolling):\u003C\u002Fp>",{"id":177,"code":178,"type":161,"language":162,"highlight":179},"b12","container.addEventListener(\"scrollend\", () => {\n  console.log(\"actually stopped\");\n  loadNextPage();\n});",[],{"id":181,"html":182,"type":140},"b13","\u003Cp>No delay to tune. No mid-gesture false positives. It fires on a plain \u003Ccode>&lt;div&gt;\u003C\u002Fcode> with \u003Ccode>overflow: auto\u003C\u002Fcode>, on \u003Ccode>document\u003C\u002Fcode>, and on \u003Ccode>window\u003C\u002Fcode> for the page itself — wherever you&#39;d have listened for \u003Ccode>scroll\u003C\u002Fcode> in the first place.\u003C\u002Fp>",{"id":184,"html":185,"type":140},"b14","\u003C!-- playground:start -->",{"id":187,"html":188,"text":188,"type":154,"level":31},"b15","🎮 Try it yourself",{"id":190,"html":191,"type":140},"b16","\u003Cp>\u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fscrollend-event-native-scroll-stop-detection\u002Fplayground\">▶️ Open the interactive playground →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":193,"html":194,"type":140},"b17","\u003Cp>\u003Cem>Runs right in your browser — poke at it and watch the concept react live.\u003C\u002Fem>\u003C\u002Fp>",{"id":196,"html":197,"type":140},"b18","\u003C!-- playground:end -->",{"id":199,"html":200,"text":200,"type":154,"level":31},"b19","The gotcha: scrollend fires for your own scrolls too",{"id":202,"html":203,"type":140},"b20","\u003Cp>\u003Ccode>scroll\u003C\u002Fcode> has always fired for programmatic scrolls as well, but a debounced \u003Ccode>scroll\u003C\u002Fcode> handler rarely moves the scroll position itself, so it almost never mattered. \u003Ccode>scrollend\u003C\u002Fcode> invites exactly that — &quot;once it stops, snap it into place&quot; — and like \u003Ccode>scroll\u003C\u002Fcode>, it fires for \u003Cem>programmatic\u003C\u002Fem> scrolling: \u003Ccode>scrollTo()\u003C\u002Fcode>, \u003Ccode>scrollBy()\u003C\u002Fcode>, \u003Ccode>scrollIntoView()\u003C\u002Fcode>, even assigning \u003Ccode>scrollTop\u003C\u002Fcode>. Anything that moves the scroll position counts, not just a finger or a wheel.\u003C\u002Fp>",{"id":205,"html":206,"type":140},"b21","\u003Cp>That means this looks reasonable and isn&#39;t:\u003C\u002Fp>",{"id":208,"code":209,"type":161,"language":162,"highlight":210},"b22","container.addEventListener(\"scrollend\", () => {\n  container.scrollTo({ top: snapToNearestSection(), behavior: \"smooth\" });\n});",[],{"id":212,"html":213,"type":140},"b23","\u003Cp>Every correction fires another \u003Ccode>scrollend\u003C\u002Fcode>, so the handler runs again. If the scroll landed exactly on target, that second call asks for the position it&#39;s already at — nothing moves, nothing fires, and it settles. But if the target you compute ever differs from where the scroll actually lands, each correction triggers the next. That&#39;s easier to hit than it sounds: in Chrome at 125% display scaling, \u003Ccode>scrollTop = 35\u003C\u002Fcode> reads back as \u003Ccode>35.2\u003C\u002Fcode>, so a \u003Ccode>snapToNearestSection\u003C\u002Fcode> built on \u003Ccode>Math.ceil\u003C\u002Fcode> sees \u003Ccode>35.2\u003C\u002Fcode>, rounds up to the \u003Cem>next\u003C\u002Fem> section, and can walk a section or two past where it should have stopped. Snap math that never returns the same target twice is worse — a fast infinite loop or a slow, visible jitter that never settles. The fix is the same guard you&#39;d want for any event you can also trigger yourself:\u003C\u002Fp>",{"id":215,"code":216,"type":161,"language":162,"highlight":217},"b24","let correcting = false;\ncontainer.addEventListener(\"scrollend\", () => {\n  if (correcting) return;\n  const target = snapToNearestSection();\n  if (Math.abs(target - container.scrollTop) \u003C 1) return; \u002F\u002F already there — don't refire\n  correcting = true;\n  container.scrollTo({ top: target, behavior: \"smooth\" });\n  container.addEventListener(\"scrollend\", () => { correcting = false; }, { once: true });\n});",[],{"id":219,"html":220,"type":140},"b25","\u003Cp>One more edge worth knowing: if a scroll call doesn&#39;t actually move the position — you ask it to scroll somewhere it already is — no \u003Ccode>scrollend\u003C\u002Fcode> fires at all, because nothing changed. That&#39;s usually convenient; it&#39;s also why the &quot;already there&quot; check above matters more than it looks like it should. A correction that doesn&#39;t move anything never fires the \u003Ccode>scrollend\u003C\u002Fcode> that resets \u003Ccode>correcting\u003C\u002Fcode>, so the user&#39;s next scroll would go uncorrected. The check compares within a pixel rather than with \u003Ccode>===\u003C\u002Fcode> because \u003Ccode>scrollTop\u003C\u002Fcode> can be fractional — that \u003Ccode>35.2\u003C\u002Fcode> again — and asking for \u003Ccode>35\u003C\u002Fcode> from there moves nothing.\u003C\u002Fp>",{"id":222,"html":223,"text":223,"type":154,"level":31},"b26","Why this is a good time to reach for it",{"id":225,"html":226,"type":140},"b27","\u003Cp>\u003Ccode>scrollend\u003C\u002Fcode> isn&#39;t new — Chrome and Firefox have shipped it since 2023. It sat unusable for anything that needed to work in Safari until Safari 26.2, released in December 2025, finally added it. That&#39;s the release that completed cross-browser Baseline coverage; before it, &quot;just use \u003Ccode>scrollend\u003C\u002Fcode>&quot; meant leaving iOS and macOS Safari users with nothing.\u003C\u002Fp>",{"id":228,"html":229,"type":140},"b28","\u003Cp>If your analytics show a meaningful slice of visitors still on a Safari version from before that release, feature-detect rather than assume:\u003C\u002Fp>",{"id":231,"code":232,"type":161,"language":162,"highlight":233},"b29","if (\"onscrollend\" in window) {\n  container.addEventListener(\"scrollend\", handleStop);\n} else {\n  \u002F\u002F last-resort debounce, only for the browsers that still need it\n  let timer;\n  container.addEventListener(\"scroll\", () => {\n    clearTimeout(timer);\n    timer = setTimeout(handleStop, 150);\n  });\n}",[],{"id":235,"html":236,"type":140},"b30","\u003Cp>That&#39;s the debounce demoted to what it always should have been: a fallback for older browsers — in practice, Safari before 26.2 — not the default answer everywhere.\u003C\u002Fp>",{"id":238,"html":239,"type":140},"b31","\u003C!-- quiz:start -->",{"id":241,"html":242,"text":242,"type":154,"level":31},"b32","🧠 Test yourself",{"id":244,"html":245,"type":140},"b33","\u003Cp>Think it clicked? \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fscrollend-event-native-scroll-stop-detection\u002Fquiz\">Take the 8-question quiz →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":247,"html":248,"type":140},"b34","\u003Cp>\u003Cem>Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.\u003C\u002Fem>\u003C\u002Fp>",{"id":250,"html":251,"type":140},"b35","\u003C!-- quiz:end -->",{"id":253,"html":254,"text":254,"type":154,"level":31},"b36","The takeaway",{"id":256,"html":257,"type":140},"b37","\u003Cp>\u003Ccode>scroll\u003C\u002Fcode> tells you movement is happening. It was never going to tell you when it&#39;s done, and no debounce delay was ever going to fix that — it could only ever narrow the guess. \u003Ccode>scrollend\u003C\u002Fcode> isn&#39;t a nicer way to detect the same thing; it&#39;s the browser finally exposing the fact you were estimating all along.\u003C\u002Fp>",{"id":259,"html":260,"type":140},"b38","\u003Cp>Got a debounced \u003Ccode>scroll\u003C\u002Fcode> handler running in production right now? Check what delay it&#39;s using — and ask yourself what it&#39;s actually protecting against if you swapped it for \u003Ccode>scrollend\u003C\u002Fcode> today.\u003C\u002Fp>",{"id":262,"html":263,"type":140},"b39","\u003C!-- related:start -->",{"id":265,"html":266,"text":266,"type":154,"level":31},"b40","📚 Read next",{"id":268,"type":269,"items":270,"ordered":18},"b41","list",[271,272,273],"\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fcss-scroll-snap-native-carousel\">I Ripped Out a Carousel Library. CSS Replaced It.\u003C\u002Fa>","\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Foverscroll-behavior-scroll-containment\">You&#39;re blocking touchmove events to contain scroll. \u003Ccode>overscroll-behavior\u003C\u002Fcode> does it natively.\u003C\u002Fa>","\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fscroll-driven-animations\">Your scroll listener is doing CSS&#39;s job\u003C\u002Fa>",{"id":275,"html":276,"type":140},"b42","\u003C!-- related:end -->",{"id":278,"type":279},"b43","divider",{"id":281,"html":282,"type":140},"b44","\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":284,"html":285,"type":140},"b45","\u003Cp>\u003Cem>Thanks for reading! Let&#39;s stay connected:\u003C\u002Fem>\u003C\u002Fp>",{"id":287,"type":269,"items":288,"ordered":18},"b46",[289,290,291],"⭐ \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>","You've built the \"load more\" trigger for an infinite-scroll feed a dozen times. The rule is always the same: don't fire the fetch on every `scroll` event — that's dozens of calls a second — fire it once, after the user actually stops.\n\nSo you debounce it. Wait 150ms after the last `scroll` event, then treat that as \"stopped,\" and fetch the next page.\n\nIt ships fine. Then someone scrolls with a trackpad, pauses for a beat with their fingers still down, and your \"stopped\" check fires mid-gesture. The fetch goes out, the list jumps, the user loses their place — while they're still scrolling.\n\nThe bug isn't your debounce delay. It's that `scroll` never tells you when scrolling is actually over. You've been asking it a question it was never built to answer.\n\n## The obvious fix, and why it's a guess\n\nHere's the version most of us have shipped:\n\n```js\nlet timer;\ncontainer.addEventListener(\"scroll\", () => {\n  clearTimeout(timer);\n  timer = setTimeout(() => {\n    console.log(\"probably stopped\");\n    loadNextPage();\n  }, 150);\n});\n```\n\nThis works *most* of the time, because most scrolling really does leave gaps longer than 150ms once it's done. But the delay is a bet, not a fact. Set it too low and any brief pause — a finger resting on the trackpad or screen mid-gesture, a slow drag — trips your timer before the user is actually done. Set it too high and every genuinely-finished scroll sits there for an extra beat before your code reacts, which reads as lag on a \"back to top\" button or a scroll-spy nav highlight.\n\nThere was never a version of this number that was simply *correct*. It couldn't be — the browser is the only thing that actually knows when a scroll gesture and its momentum are done, and it wasn't telling you.\n\n## The event that answers the actual question\n\nIt does now. `scrollend` fires once, on the same target you'd attach `scroll` to, exactly when the browser considers the scroll position to have finished changing — the user's gesture has ended *and* there are no more pending position updates (including the tail end of momentum scrolling):\n\n```js\ncontainer.addEventListener(\"scrollend\", () => {\n  console.log(\"actually stopped\");\n  loadNextPage();\n});\n```\n\nNo delay to tune. No mid-gesture false positives. It fires on a plain `\u003Cdiv>` with `overflow: auto`, on `document`, and on `window` for the page itself — wherever you'd have listened for `scroll` in the first place.\n\n\u003C!-- playground:start -->\n\n## 🎮 Try it yourself\n\n**[▶️ Open the interactive playground →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fscrollend-event-native-scroll-stop-detection\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 gotcha: scrollend fires for your own scrolls too\n\n`scroll` has always fired for programmatic scrolls as well, but a debounced `scroll` handler rarely moves the scroll position itself, so it almost never mattered. `scrollend` invites exactly that — \"once it stops, snap it into place\" — and like `scroll`, it fires for *programmatic* scrolling: `scrollTo()`, `scrollBy()`, `scrollIntoView()`, even assigning `scrollTop`. Anything that moves the scroll position counts, not just a finger or a wheel.\n\nThat means this looks reasonable and isn't:\n\n```js\ncontainer.addEventListener(\"scrollend\", () => {\n  container.scrollTo({ top: snapToNearestSection(), behavior: \"smooth\" });\n});\n```\n\nEvery correction fires another `scrollend`, so the handler runs again. If the scroll landed exactly on target, that second call asks for the position it's already at — nothing moves, nothing fires, and it settles. But if the target you compute ever differs from where the scroll actually lands, each correction triggers the next. That's easier to hit than it sounds: in Chrome at 125% display scaling, `scrollTop = 35` reads back as `35.2`, so a `snapToNearestSection` built on `Math.ceil` sees `35.2`, rounds up to the *next* section, and can walk a section or two past where it should have stopped. Snap math that never returns the same target twice is worse — a fast infinite loop or a slow, visible jitter that never settles. The fix is the same guard you'd want for any event you can also trigger yourself:\n\n```js\nlet correcting = false;\ncontainer.addEventListener(\"scrollend\", () => {\n  if (correcting) return;\n  const target = snapToNearestSection();\n  if (Math.abs(target - container.scrollTop) \u003C 1) return; \u002F\u002F already there — don't refire\n  correcting = true;\n  container.scrollTo({ top: target, behavior: \"smooth\" });\n  container.addEventListener(\"scrollend\", () => { correcting = false; }, { once: true });\n});\n```\n\nOne more edge worth knowing: if a scroll call doesn't actually move the position — you ask it to scroll somewhere it already is — no `scrollend` fires at all, because nothing changed. That's usually convenient; it's also why the \"already there\" check above matters more than it looks like it should. A correction that doesn't move anything never fires the `scrollend` that resets `correcting`, so the user's next scroll would go uncorrected. The check compares within a pixel rather than with `===` because `scrollTop` can be fractional — that `35.2` again — and asking for `35` from there moves nothing.\n\n## Why this is a good time to reach for it\n\n`scrollend` isn't new — Chrome and Firefox have shipped it since 2023. It sat unusable for anything that needed to work in Safari until Safari 26.2, released in December 2025, finally added it. That's the release that completed cross-browser Baseline coverage; before it, \"just use `scrollend`\" meant leaving iOS and macOS Safari users with nothing.\n\nIf your analytics show a meaningful slice of visitors still on a Safari version from before that release, feature-detect rather than assume:\n\n```js\nif (\"onscrollend\" in window) {\n  container.addEventListener(\"scrollend\", handleStop);\n} else {\n  \u002F\u002F last-resort debounce, only for the browsers that still need it\n  let timer;\n  container.addEventListener(\"scroll\", () => {\n    clearTimeout(timer);\n    timer = setTimeout(handleStop, 150);\n  });\n}\n```\n\nThat's the debounce demoted to what it always should have been: a fallback for older browsers — in practice, Safari before 26.2 — not the default answer everywhere.\n\n\u003C!-- quiz:start -->\n\n## 🧠 Test yourself\n\nThink it clicked? **[Take the 8-question quiz →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fscrollend-event-native-scroll-stop-detection\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## The takeaway\n\n`scroll` tells you movement is happening. It was never going to tell you when it's done, and no debounce delay was ever going to fix that — it could only ever narrow the guess. `scrollend` isn't a nicer way to detect the same thing; it's the browser finally exposing the fact you were estimating all along.\n\nGot a debounced `scroll` handler running in production right now? Check what delay it's using — and ask yourself what it's actually protecting against if you swapped it for `scrollend` today.\n\n\u003C!-- related:start -->\n\n## 📚 Read next\n\n- [I Ripped Out a Carousel Library. CSS Replaced It.](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fcss-scroll-snap-native-carousel)\n- [You're blocking touchmove events to contain scroll. `overscroll-behavior` does it natively.](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Foverscroll-behavior-scroll-containment)\n- [Your scroll listener is doing CSS's job](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fscroll-driven-animations)\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":110,"canonical":294,"description":295},"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fscrollend-event-native-scroll-stop-detection","For years, 'has the user stopped scrolling?' was a guess: debounce the scroll event, pick a delay, hope. The scrollend event answers the question directly — and as of Safari 26.2, ","01a0ec73-0e91-7572-8a40-0bb438f4ae0c",{"id":298,"locked":18},"01a0ec73-0ebd-77c9-b956-b53385b7570f",[300],{"id":45,"slug":46,"title":48,"_count":301},{"questions":51},[303],{"locale":13,"slug":46},{"id":45,"slug":46,"title":48,"_count":305,"questionCount":51},{"questions":51}]