Debounce, Throttle, and requestAnimationFrame
Find out why scroll, resize, input, and mousemove can drag down performance, and learn when to reach for debounce, throttle, or requestAnimationFrame, and how to implement each one.
Why Do High-Frequency Events Hurt Performance?
Quick guess: when a user moves their mouse across the page, how many mousemove events does the browser fire per second? There's no fixed answer, but dozens or even hundreds are common. scroll, resize, touchmove, and input are the same kind of thing. They're all high-frequency events: the user makes one continuous gesture, and the browser throws a whole stream of events at you.
The events themselves aren't expensive. What's expensive is what you do inside the event handler. JavaScript shares the main thread with page rendering. A 60 Hz display needs 60 frames per second, which leaves roughly 16.7 ms per frame, and that budget has to cover JavaScript execution, style calculation, layout, paint, and everything else.
Say you do this inside a scroll handler:
window.addEventListener('scroll', () => {
const top = header.getBoundingClientRect().top // forces layout
header.style.transform = `translateY(${top}px)` // triggers a style update
fetch(`/api/track?y=${window.scrollY}`) // sends a request every time
})If the handler takes longer than 16.7 ms, or the events come in so fast that work piles up in a long queue, the browser can't update the screen in time, and the user sees jank. It gets worse if the handler interleaves "read layout info" and "write styles", because that can trigger repeated forced layouts (layout thrashing) and multiply the cost several times over.
So the real problem isn't that there are too many events. It's that you don't need to do a full round of work for every single one. Rate limiting is about exactly that: boiling dozens of events down to the handful you actually need to handle.
This article covers three techniques:
- Debounce: wait until the user stops, then act.
- Throttle: act at most once per time interval.
- requestAnimationFrame: act in sync with the browser's repaint cycle, at most once per frame.
Debounce: Wait for Things to Calm Down
Think of debounce as an elevator door. Every time someone holds the open button, the elevator restarts its wait, and the door only closes once everyone has stopped pressing.
Applied to events: every incoming event resets a timer, and the handler only runs once there have been no new events for N milliseconds.
function debounce(fn, wait) {
let timerId = null
function debounced(...args) {
clearTimeout(timerId) // cancel the previously scheduled call every time
timerId = setTimeout(() => {
timerId = null
fn.apply(this, args)
}, wait)
}
debounced.cancel = () => {
clearTimeout(timerId)
timerId = null
}
return debounced
}The classic use case is a search box. When a user types the 10 letters of javascript, you don't need to fire 10 requests. Just wait until they've paused for 300 ms, then query once with the final string:
const search = debounce((keyword) => {
fetch(`/api/search?q=${encodeURIComponent(keyword)}`)
}, 300)
input.addEventListener('input', (e) => search(e.target.value))Leading vs. Trailing
The version above is trailing: it runs after things have settled. Sometimes you want to respond to the very first event right away and ignore everything after it. That's leading. A good example is stopping users from mashing a submit button:
function debounceLeading(fn, wait) {
let timerId = null
return function (...args) {
const isFirst = timerId === null
clearTimeout(timerId)
timerId = setTimeout(() => {
timerId = null
}, wait)
if (isFirst) fn.apply(this, args) // run immediately on the first call
}
}The Debounce Trap
Debounce has one important property: as long as events keep coming, it never runs. Put it on scroll, and if the user keeps scrolling, your handler does nothing the whole time and only fires once they stop. For anything that needs live feedback, that's a dealbreaker. That's where throttle comes in.
Throttle: A Steady Rhythm, at Most Once
Throttle works like a bus on a fixed schedule. No matter how many passengers show up, a bus leaves every N minutes. It doesn't add extra buses when it's crowded, and it doesn't skip a departure when it's empty.
The most common implementation uses timestamps to check whether enough time has passed since the last run:
function throttle(fn, interval) {
let lastTime = 0
return function (...args) {
const now = Date.now()
if (now - lastTime >= interval) {
lastTime = now
fn.apply(this, args)
}
}
}
window.addEventListener('scroll', throttle(updateProgressBar, 100))This version is leading-style: the first event runs immediately, and every event in the next 100 ms is dropped. The downside is that the last event can get swallowed. If the user stops scrolling partway through an interval, the final scroll position never gets processed, and the UI stays stuck in a slightly earlier state.
Adding Trailing So the Last Event Isn't Lost
To fix that, you can schedule a timer for the events that get dropped, which guarantees the latest state gets handled at least once more after the interval ends:
function throttle(fn, interval) {
let lastTime = 0
let timerId = null
let lastArgs = null
return function (...args) {
const now = Date.now()
const remaining = interval - (now - lastTime)
lastArgs = args
if (remaining <= 0) {
// the interval has passed, run immediately (leading)
clearTimeout(timerId)
timerId = null
lastTime = now
fn.apply(this, lastArgs)
} else if (timerId === null) {
// still within the interval, schedule a trailing run
timerId = setTimeout(() => {
lastTime = Date.now()
timerId = null
fn.apply(this, lastArgs)
}, remaining)
}
}
}Now the handler runs once every 100 ms while the user is scrolling, and runs one last time when they stop. In practice, you'll usually just use lodash's _.throttle and _.debounce. They already handle options like leading, trailing, cancel, and flush, so there's no need to roll your own.
Debounce or Throttle?
The biggest difference comes down to one line: debounce cares about when it ends, and throttle cares about the rhythm along the way.
| Scenario | Recommendation | Why |
|---|---|---|
| Search box input, autocomplete | Debounce | You only care what the user finally typed |
| Live form validation | Debounce | Wait until the user finishes a field, then validate |
| Recalculating layout after resize | Debounce | You only need the final size, not the sizes along the way |
| Autosaving drafts | Debounce | Save after the user pauses, not on every keystroke |
| Scroll progress bar, infinite scroll | Throttle | You need continuous feedback, but not on every event |
| Sending coordinates while dragging or tracking the mouse | Throttle | You need continuous updates, but with a capped rate |
| Preventing double clicks | Leading | The first click takes effect immediately, the rest are ignored |
If you look closely, you'll notice one kind of scenario isn't covered: animation-style work that updates the screen, like making an element follow the mouse, parallax scrolling, or fading something based on scroll position. For these, "once every 100 ms" or "once every 300 ms" both feel off, and the next section explains why.
Why Animations Need requestAnimationFrame
A throttle interval is something you pick by feel: 16 ms, 50 ms, 100 ms. But the moments when the browser actually updates the screen have nothing to do with your setTimeout.
Say you use throttle(update, 16) to try to match 60 FPS. You'll run into a few problems:
- Out of phase: the timer might fire right before a frame update, or right after one. Some frames end up doing the work twice for nothing, and others have no fresh data at all.
- The refresh rate isn't always 60 Hz: on a 120 Hz display, each frame is only about 8.3 ms, so a fixed 16 ms throttle wastes half the frames.
- Timers aren't precise:
setTimeoutgets delayed by other work on the main thread, so the actual firing time jitters.
requestAnimationFrame (rAF for short) was built to solve exactly this. What it says is: "Browser, call this callback before the next repaint." The browser puts the callback early in the rendering pipeline, before style calculation and layout. So any style changes you make inside it show up in that very frame, with no extra render.
It gives you three benefits:
- It syncs with the refresh rate automatically: a 60 Hz display calls it at most 60 times per second, a 120 Hz display 120 times, and you don't have to do any math.
- It runs at most once per frame: no work that gets computed but never seen.
- It pauses in background tabs: when a tab goes to the background, the browser usually pauses rAF, so you don't burn battery on frames nobody can see.
Throttling Events with rAF
rAF isn't a throttle by itself. It just means "run on the next frame." To use it to throttle high-frequency events, the standard pattern is a flag that records whether work has already been scheduled for this frame:
let latestY = 0
let ticking = false
function update() {
header.style.transform = `translateY(${latestY * 0.5}px)` // parallax effect
ticking = false // this frame is done, allow the next schedule
}
window.addEventListener(
'scroll',
() => {
latestY = window.scrollY // just record the latest value, no heavy work in the event
if (!ticking) {
ticking = true
requestAnimationFrame(update)
}
},
{ passive: true },
)Here's how it flows:
- Every
scrollevent does the lightest possible thing: it stores the latest position. - If no rAF has been scheduled for this frame yet, it schedules one. If one has, it skips.
- Before the next frame,
updateis called. It reads the latest position and then resets the flag.
Whether scroll fires once or ten times between two frames, the screen only gets updated once, and always with the freshest data. This split, where events collect and rAF applies, is a very reliable pattern for animation-style updates.
The { passive: true } above is worth a quick mention too. It tells the browser your handler won't call preventDefault(), so the browser doesn't have to wait for the handler to finish before it starts scrolling. That makes a real difference to smoothness for events like scroll, touchmove, and wheel.
Pulling It into a Reusable Helper
You can wrap the same logic into a general-purpose rafThrottle:
function rafThrottle(fn) {
let rafId = null
let lastArgs = null
function throttled(...args) {
lastArgs = args // always keep only the latest arguments
if (rafId !== null) return
rafId = requestAnimationFrame(() => {
rafId = null
fn(...lastArgs)
})
}
throttled.cancel = () => {
cancelAnimationFrame(rafId)
rafId = null
}
return throttled
}
const onMove = rafThrottle((x, y) => {
cursor.style.transform = `translate(${x}px, ${y}px)`
})
window.addEventListener('mousemove', (e) => onMove(e.clientX, e.clientY))Compared to a regular throttle, the difference is that there's no interval parameter. The browser fully decides the rhythm.
Separating Reads from Writes to Avoid Layout Thrashing
rAF has another nice property: it gives you one central place to handle DOM work. Inside a single callback, it's best to batch all your reads first, then batch all your writes:
requestAnimationFrame(() => {
// read first
const heights = items.map((el) => el.offsetHeight)
// then write
items.forEach((el, i) => {
el.style.height = `${heights[i] + 10}px`
})
})If you interleave them, "read one, write one," every read forces the browser to settle the earlier changes into a layout first, and the cost multiplies.
When Not to Use rAF
rAF isn't a silver bullet. Before you reach for it, make sure the work is about updating the screen.
- Network requests: search suggestions and autosave should use debounce. rAF fires dozens of times per second, so using it to send requests will hammer your backend.
- Work that doesn't affect the screen: sending analytics events, for example. Throttle or debounce is enough to control the rate.
- Logic that needs a fixed time interval: rAF's interval changes with the refresh rate and pauses in background tabs, so it's a bad fit for anything with real time semantics like "once every second."
Another common misconception is that a fast rAF callback can't cause jank. In fact, if the callback itself blows past the frame budget, you'll still drop frames. rAF only guarantees the work is scheduled at the right moment. It doesn't make heavy work any lighter.
Clean Up After Yourself
With any kind of throttling, if there's a timer or a scheduled callback, you need to clean it up when the component unmounts. Here's what that looks like in React:
useEffect(() => {
const onMove = rafThrottle((x, y) => setPosition({ x, y }))
const handler = (e) => onMove(e.clientX, e.clientY)
window.addEventListener('mousemove', handler)
return () => {
window.removeEventListener('mousemove', handler)
onMove.cancel() // cancel any scheduled call that hasn't run yet
}
}, [])One more thing to watch: the throttled function has to be the same instance across renders. If you recreate debounce(...) on every render, you get a brand-new timer each time, which means no throttling at all. Use useMemo or useRef, or define it outside the component.
Is There a Better Option?
One last important idea: often the best throttling is not listening to high-frequency events at all. Modern browsers offer several dedicated APIs:
- IntersectionObserver: detects whether an element has entered the viewport, replacing the old approach of calculating positions in a
scrollhandler. It's commonly used for lazy loading and infinite scroll. - ResizeObserver: watches element size changes. It's more precise than listening to
window.resize, and the browser schedules it as part of the rendering pipeline. - CSS scroll-driven animations: bind an animation directly to scroll progress in CSS, with no JavaScript at all. The major browsers are gradually adding support, so check support in your target browsers before you rely on it.
Wrapping Up
The problem with high-frequency events isn't how many there are. It's that you end up doing unnecessary work every time. Each of the three techniques has its own specialty:
- Debounce: wait until the user stops, then act. Great for search, validation, and autosave, where only the final result matters.
- Throttle: act at most once per interval. Great when you need continuous feedback but don't have to handle every single event.
- requestAnimationFrame: follow the browser's repaint rhythm and act at most once per frame. Great for any animation-style update that directly changes what's on screen.