[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"verticals":3,"quiz-requestanimationframe-scroll-throttling":44,"search-suggestions":60,"quiz-article-requestanimationframe-scroll-throttling":108,"path-membership-requestanimationframe-scroll-throttling":276},[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},"01a15d15-2556-7649-bd0c-52778ac6251e","requestanimationframe-scroll-throttling","PRACTICE_QUIZ","Debounce, throttle, and requestAnimationFrame","Check whether you've got the difference between waiting for scroll to stop and syncing to the browser's own paint clock.",{"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,100,104],{"slug":62,"name":63,"articles":64},"webdev","Webdev",154,{"slug":66,"name":67,"articles":68},"javascript","Javascript",128,{"slug":70,"name":71,"articles":72},"frontend","Frontend",88,{"slug":74,"name":75,"articles":76},"tutorial","Tutorial",60,{"slug":78,"name":79,"articles":80},"css","Css",45,{"slug":82,"name":83,"articles":84},"performance","Performance",26,{"slug":86,"name":87,"articles":88},"typescript","Typescript",22,{"slug":90,"name":91,"articles":92},"react","React",19,{"slug":94,"name":95,"articles":96},"programming","Programming",14,{"slug":98,"name":99,"articles":96},"browser","Browser",{"slug":101,"name":102,"articles":103},"node","Node",13,{"slug":105,"name":106,"articles":107},"accessibility","Accessibility",10,{"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":129,"playground":131,"body":133,"bodyMd":262,"seo":263,"translationGroupId":266,"series":52,"podcastUrl":52,"verticalId":5,"thread":267,"assessments":269,"translations":272,"quiz":274},"01a15d15-24b7-7510-b76c-15fc8d3b2f58","You're Debouncing Scroll Wrong. requestAnimationFrame Fixes It.","Debouncing a scroll handler makes it wait until scrolling stops — which is why your sticky header always lags half a beat. requestAnimationFrame fixes the lag by syncing updates to the browser's own paint clock instead of a timer.","\u002Fmedia\u002Fcovers\u002Frequestanimationframe-scroll-throttling.png",5,"2026-10-26T05:39:27.008Z",35,{"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,128],{"slug":66,"name":67,"color":52},{"slug":62,"name":63,"color":52},{"slug":82,"name":83,"color":52},{"slug":94,"name":95,"color":52},{"assessments":130},1,{"slug":46,"title":132},"requestAnimationFrame vs debounce vs timer-throttle — interactive playground",{"blocks":134,"version":130},[135,139,142,147,150,156,159,162,165,169,172,176,179,182,185,188,192,195,198,201,204,207,210,213,216,219,222,225,228,231,234,237,240,243,246,249,252,255],{"id":136,"html":137,"type":138},"b1","\u003Cp>Our sticky header started lagging behind the scroll by about half a second. Not broken — just late, like it was watching the page over a bad video call. Scroll up fast and the header&#39;s drop shadow would still be fading in a beat after you&#39;d already stopped.\u003C\u002Fp>","paragraph",{"id":140,"html":141,"type":138},"b2","\u003Cp>The code looked right. It was debounced, just like the resize handler two files over, just like every &quot;optimize this event listener&quot; article says to do. Debouncing a scroll handler is the move everyone learns for \u003Cem>performance\u003C\u002Fem> reasons. Nobody mentions it also breaks the thing you were trying to show the user in real time.\u003C\u002Fp>",{"id":143,"html":144,"text":145,"type":146,"level":31},"b3","The handler that was &quot;optimized&quot; into being wrong","The handler that was \"optimized\" into being wrong","heading",{"id":148,"html":149,"type":138},"b4","\u003Cp>Here&#39;s roughly what was wrapping the scroll listener:\u003C\u002Fp>",{"id":151,"code":152,"type":153,"language":154,"highlight":155},"b5","function debounce(fn, wait) {\n  let timer;\n  return (...args) => {\n    clearTimeout(timer);\n    timer = setTimeout(() => fn(...args), wait);\n  };\n}\n\nwindow.addEventListener(\"scroll\", debounce(updateHeader, 150));","code","js",[],{"id":157,"html":158,"type":138},"b6","\u003Cp>\u003Ccode>debounce\u003C\u002Fcode> resets its timer on every single scroll event and only fires once the events \u003Cem>stop coming\u003C\u002Fem> — once you stop scrolling. That&#39;s exactly what you want for a resize handler recalculating a layout: you don&#39;t care about every intermediate width, just the final one, and recalculating 40 times mid-drag is wasted work.\u003C\u002Fp>",{"id":160,"html":161,"type":138},"b7","\u003Cp>A sticky header isn&#39;t that. The user wants to see the shadow and background opacity change \u003Cem>as they scroll\u003C\u002Fem>, not 150ms after they&#39;ve already arrived. Debounce doesn&#39;t throttle the rate of updates — it delays the update until the interaction is over. For anything the user is supposed to watch happen live, that&#39;s backwards.\u003C\u002Fp>",{"id":163,"html":164,"type":138},"b8","\u003Cp>Quick gut check before you keep reading: have you ever debounced a scroll or \u003Ccode>mousemove\u003C\u002Fcode> handler because that&#39;s just &quot;what you do&quot; for frequent events, without asking whether debounce or throttle was the one you actually needed?\u003C\u002Fp>",{"id":166,"html":167,"text":168,"type":146,"level":31},"b9","Throttling fixes the lag. It doesn&#39;t fix the timing.","Throttling fixes the lag. It doesn't fix the timing.",{"id":170,"html":171,"type":138},"b10","\u003Cp>The standard next move is to swap \u003Ccode>debounce\u003C\u002Fcode> for \u003Ccode>throttle\u003C\u002Fcode> — run the handler on a fixed interval instead of waiting for silence:\u003C\u002Fp>",{"id":173,"code":174,"type":153,"language":154,"highlight":175},"b11","function throttle(fn, wait) {\n  let last = 0;\n  return (...args) => {\n    const now = Date.now();\n    if (now - last >= wait) {\n      last = now;\n      fn(...args);\n    }\n  };\n}\n\nwindow.addEventListener(\"scroll\", throttle(updateHeader, 16));",[],{"id":177,"html":178,"type":138},"b12","\u003Cp>This is closer — the header updates \u003Cem>during\u003C\u002Fem> the scroll now. But look at that \u003Ccode>16\u003C\u002Fcode>. Where did it come from? It&#39;s &quot;roughly one frame at 60Hz,&quot; typed in because that&#39;s the number every blog post uses. It assumes the display is 60Hz. A lot of phones and newer monitors run at 90Hz, 120Hz, even 144Hz — on those, \u003Ccode>16\u003C\u002Fcode> isn&#39;t &quot;once per frame&quot; anymore, it&#39;s &quot;slower than the screen can actually paint,&quot; and the update looks subtly behind even though your throttle is technically running.\u003C\u002Fp>",{"id":180,"html":181,"type":138},"b13","\u003Cp>You&#39;re also still paying for a \u003Ccode>Date.now()\u003C\u002Fcode> call and a comparison on every scroll event, even the ones you throw away. The scroll event itself can fire dozens of times between two paints. All of that extra firing is wasted — the browser is only going to show the user a new frame once, no matter how many times you ran your handler before it.\u003C\u002Fp>",{"id":183,"html":184,"text":184,"type":146,"level":31},"b14","The browser already has a clock for this",{"id":186,"html":187,"type":138},"b15","\u003Cp>Here&#39;s the part that&#39;s easy to miss: the browser knows exactly when it&#39;s about to paint the next frame. You don&#39;t have to estimate it with a \u003Ccode>setTimeout\u003C\u002Fcode> interval — you can ask for it directly with \u003Ccode>requestAnimationFrame\u003C\u002Fcode>.\u003C\u002Fp>",{"id":189,"code":190,"type":153,"language":154,"highlight":191},"b16","let ticking = false;\n\nfunction onScroll() {\n  if (ticking) return;\n  ticking = true;\n  requestAnimationFrame(() => {\n    updateHeader();\n    ticking = false;\n  });\n}\n\nwindow.addEventListener(\"scroll\", onScroll, { passive: true });",[],{"id":193,"html":194,"type":138},"b17","\u003Cp>The \u003Ccode>ticking\u003C\u002Fcode> flag is the whole trick. \u003Ccode>scroll\u003C\u002Fcode> can fire many times before the next paint; the flag makes sure only the \u003Cem>first\u003C\u002Fem> one of those schedules an update, and every scroll event after it until the next frame is a no-op — a single boolean check, nothing more. When the browser is finally ready to paint, it calls your callback once, you update the header with the latest scroll position, and you flip the flag back so the next scroll event can schedule the next frame.\u003C\u002Fp>",{"id":196,"html":197,"type":138},"b18","\u003Cp>No guessed interval. No wasted \u003Ccode>setTimeout\u003C\u002Fcode> calls that get cleared before they fire. No lag after the user stops, because there&#39;s no waiting-for-silence step at all — the update rides along with the very next frame the browser was already going to paint, on a 60Hz screen or a 120Hz one.\u003C\u002Fp>",{"id":199,"html":200,"type":138},"b19","\u003Cp>\u003Ccode>{ passive: true }\u003C\u002Fcode> is still worth keeping on the listener, but be precise about why: \u003Ccode>scroll\u003C\u002Fcode> itself isn&#39;t cancelable, so \u003Ccode>preventDefault()\u003C\u002Fcode> inside a scroll handler does nothing either way — passive or not. The option earns its keep on events that \u003Cem>are\u003C\u002Fem> cancelable, like \u003Ccode>touchmove\u003C\u002Fcode> and \u003Ccode>wheel\u003C\u002Fcode>, where it tells the browser you&#39;ll never call \u003Ccode>preventDefault()\u003C\u002Fcode> so it can start scrolling immediately instead of waiting to find out. Add it to the scroll listener anyway as the correct default for any handler that never cancels anything.\u003C\u002Fp>",{"id":202,"html":203,"type":138},"b20","\u003C!-- playground:start -->",{"id":205,"html":206,"text":206,"type":146,"level":31},"b21","🎮 Try it yourself",{"id":208,"html":209,"type":138},"b22","\u003Cp>\u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Frequestanimationframe-scroll-throttling\u002Fplayground\">▶️ Open the interactive playground →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":211,"html":212,"type":138},"b23","\u003Cp>\u003Cem>Runs right in your browser — poke at it and watch the concept react live.\u003C\u002Fem>\u003C\u002Fp>",{"id":214,"html":215,"type":138},"b24","\u003C!-- playground:end -->",{"id":217,"html":218,"text":218,"type":146,"level":31},"b25","When you still want debounce",{"id":220,"html":221,"type":138},"b26","\u003Cp>None of this makes \u003Ccode>debounce\u003C\u002Fcode> wrong in general — it just means the choice depends on what the user is waiting for. A search-as-you-type box \u003Cem>should\u003C\u002Fem> wait for the person to stop typing. A &quot;save draft&quot; call \u003Cem>should\u003C\u002Fem> wait for a pause in edits. A window-resize recalculation that&#39;s expensive \u003Cem>should\u003C\u002Fem> wait until the user&#39;s done dragging the edge of the browser.\u003C\u002Fp>",{"id":223,"html":224,"type":138},"b27","\u003Cp>The question that decides it: does the user need to see this update \u003Cem>during\u003C\u002Fem> the interaction, or only \u003Cem>after\u003C\u002Fem> it settles? Live visual feedback — scroll position, drag position, pointer tracking — wants a per-frame throttle, and \u003Ccode>requestAnimationFrame\u003C\u002Fcode> is the native, interval-free way to get one. Anything that&#39;s expensive and only matters at the end wants debounce.\u003C\u002Fp>",{"id":226,"html":227,"type":138},"b28","\u003Cp>The header stopped lagging the day we swapped the \u003Ccode>setTimeout\u003C\u002Fcode> number for a question the browser could just answer itself.\u003C\u002Fp>",{"id":229,"html":230,"type":138},"b29","\u003Cp>What&#39;s debounced in your codebase right now that&#39;s actually supposed to be live? Go check your scroll and \u003Ccode>mousemove\u003C\u002Fcode> handlers — I&#39;d bet at least one of them is waiting for silence when it should be riding the next frame.\u003C\u002Fp>",{"id":232,"html":233,"type":138},"b30","\u003C!-- quiz:start -->",{"id":235,"html":236,"text":236,"type":146,"level":31},"b31","🧠 Test yourself",{"id":238,"html":239,"type":138},"b32","\u003Cp>Think it clicked? \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Frequestanimationframe-scroll-throttling\u002Fquiz\">Take the 8-question quiz →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":241,"html":242,"type":138},"b33","\u003Cp>\u003Cem>Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.\u003C\u002Fem>\u003C\u002Fp>",{"id":244,"html":245,"type":138},"b34","\u003C!-- quiz:end -->",{"id":247,"type":248},"b35","divider",{"id":250,"html":251,"type":138},"b36","\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":253,"html":254,"type":138},"b37","\u003Cp>\u003Cem>Thanks for reading! Let&#39;s stay connected:\u003C\u002Fem>\u003C\u002Fp>",{"id":256,"type":257,"items":258,"ordered":18},"b38","list",[259,260,261],"⭐ \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>","Our sticky header started lagging behind the scroll by about half a second. Not broken — just late, like it was watching the page over a bad video call. Scroll up fast and the header's drop shadow would still be fading in a beat after you'd already stopped.\n\nThe code looked right. It was debounced, just like the resize handler two files over, just like every \"optimize this event listener\" article says to do. Debouncing a scroll handler is the move everyone learns for *performance* reasons. Nobody mentions it also breaks the thing you were trying to show the user in real time.\n\n## The handler that was \"optimized\" into being wrong\n\nHere's roughly what was wrapping the scroll listener:\n\n```js\nfunction debounce(fn, wait) {\n  let timer;\n  return (...args) => {\n    clearTimeout(timer);\n    timer = setTimeout(() => fn(...args), wait);\n  };\n}\n\nwindow.addEventListener(\"scroll\", debounce(updateHeader, 150));\n```\n\n`debounce` resets its timer on every single scroll event and only fires once the events *stop coming* — once you stop scrolling. That's exactly what you want for a resize handler recalculating a layout: you don't care about every intermediate width, just the final one, and recalculating 40 times mid-drag is wasted work.\n\nA sticky header isn't that. The user wants to see the shadow and background opacity change *as they scroll*, not 150ms after they've already arrived. Debounce doesn't throttle the rate of updates — it delays the update until the interaction is over. For anything the user is supposed to watch happen live, that's backwards.\n\nQuick gut check before you keep reading: have you ever debounced a scroll or `mousemove` handler because that's just \"what you do\" for frequent events, without asking whether debounce or throttle was the one you actually needed?\n\n## Throttling fixes the lag. It doesn't fix the timing.\n\nThe standard next move is to swap `debounce` for `throttle` — run the handler on a fixed interval instead of waiting for silence:\n\n```js\nfunction throttle(fn, wait) {\n  let last = 0;\n  return (...args) => {\n    const now = Date.now();\n    if (now - last >= wait) {\n      last = now;\n      fn(...args);\n    }\n  };\n}\n\nwindow.addEventListener(\"scroll\", throttle(updateHeader, 16));\n```\n\nThis is closer — the header updates *during* the scroll now. But look at that `16`. Where did it come from? It's \"roughly one frame at 60Hz,\" typed in because that's the number every blog post uses. It assumes the display is 60Hz. A lot of phones and newer monitors run at 90Hz, 120Hz, even 144Hz — on those, `16` isn't \"once per frame\" anymore, it's \"slower than the screen can actually paint,\" and the update looks subtly behind even though your throttle is technically running.\n\nYou're also still paying for a `Date.now()` call and a comparison on every scroll event, even the ones you throw away. The scroll event itself can fire dozens of times between two paints. All of that extra firing is wasted — the browser is only going to show the user a new frame once, no matter how many times you ran your handler before it.\n\n## The browser already has a clock for this\n\nHere's the part that's easy to miss: the browser knows exactly when it's about to paint the next frame. You don't have to estimate it with a `setTimeout` interval — you can ask for it directly with `requestAnimationFrame`.\n\n```js\nlet ticking = false;\n\nfunction onScroll() {\n  if (ticking) return;\n  ticking = true;\n  requestAnimationFrame(() => {\n    updateHeader();\n    ticking = false;\n  });\n}\n\nwindow.addEventListener(\"scroll\", onScroll, { passive: true });\n```\n\nThe `ticking` flag is the whole trick. `scroll` can fire many times before the next paint; the flag makes sure only the *first* one of those schedules an update, and every scroll event after it until the next frame is a no-op — a single boolean check, nothing more. When the browser is finally ready to paint, it calls your callback once, you update the header with the latest scroll position, and you flip the flag back so the next scroll event can schedule the next frame.\n\nNo guessed interval. No wasted `setTimeout` calls that get cleared before they fire. No lag after the user stops, because there's no waiting-for-silence step at all — the update rides along with the very next frame the browser was already going to paint, on a 60Hz screen or a 120Hz one.\n\n`{ passive: true }` is still worth keeping on the listener, but be precise about why: `scroll` itself isn't cancelable, so `preventDefault()` inside a scroll handler does nothing either way — passive or not. The option earns its keep on events that *are* cancelable, like `touchmove` and `wheel`, where it tells the browser you'll never call `preventDefault()` so it can start scrolling immediately instead of waiting to find out. Add it to the scroll listener anyway as the correct default for any handler that never cancels anything.\n\n\u003C!-- playground:start -->\n\n## 🎮 Try it yourself\n\n**[▶️ Open the interactive playground →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Frequestanimationframe-scroll-throttling\u002Fplayground)**\n\n_Runs right in your browser — poke at it and watch the concept react live._\n\n\u003C!-- playground:end -->\n\n## When you still want debounce\n\nNone of this makes `debounce` wrong in general — it just means the choice depends on what the user is waiting for. A search-as-you-type box *should* wait for the person to stop typing. A \"save draft\" call *should* wait for a pause in edits. A window-resize recalculation that's expensive *should* wait until the user's done dragging the edge of the browser.\n\nThe question that decides it: does the user need to see this update *during* the interaction, or only *after* it settles? Live visual feedback — scroll position, drag position, pointer tracking — wants a per-frame throttle, and `requestAnimationFrame` is the native, interval-free way to get one. Anything that's expensive and only matters at the end wants debounce.\n\nThe header stopped lagging the day we swapped the `setTimeout` number for a question the browser could just answer itself.\n\nWhat's debounced in your codebase right now that's actually supposed to be live? Go check your scroll and `mousemove` handlers — I'd bet at least one of them is waiting for silence when it should be riding the next frame.\n\n\u003C!-- quiz:start -->\n\n## 🧠 Test yourself\n\nThink it clicked? **[Take the 8-question quiz →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Frequestanimationframe-scroll-throttling\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---\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":264,"description":265},"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Frequestanimationframe-scroll-throttling","Debouncing a scroll handler makes it wait until scrolling stops — which is why your sticky header always lags half a beat. requestAnimationFrame fixes the lag by syncing updates to","01a15d15-24b7-7510-b76c-19695e5cac20",{"id":268,"locked":18},"01a15d15-2529-774e-8a8a-4a59a9573250",[270],{"id":45,"slug":46,"title":48,"_count":271},{"questions":51},[273],{"locale":13,"slug":46},{"id":45,"slug":46,"title":48,"_count":275,"questionCount":51},{"questions":51},[]]