Debounce, throttle, and requestAnimationFrame
Three patterns for making code run *less often*. Pick the right one and most jank disappears.
You wire up a search box that filters as the user types. They type "sourdough" — eight characters — and your filter function runs eight times in 400 milliseconds. Most of those runs are wasted; only the last one's result is the one the user sees. The first seven made the page feel sluggish.
You add a scroll listener that updates a sticky-header layout. Scrolling fires the listener dozens of times per second. Each call does a layout calculation. The page hitches.
You write an animation that updates an element's position based on the mouse. It runs on every mousemove. The element drifts behind the cursor.
Three different bugs, all the same shape: a function is running more often than it usefully can. The fix is one of three patterns — debounce, throttle, or requestAnimationFrame. Each fits a different shape of problem. Knowing which to reach for is most of the lesson.
The shape of the problem
The browser fires events at whatever rate the source produces them. Typing fires input events as fast as keystrokes. Scrolling can fire scroll events at hundreds of hertz on a high-refresh-rate trackpad. Resizing fires constantly during a window drag.
Your handler doesn't get to slow the events down — they keep coming. But you do get to choose how often you react to them. The three patterns in this lesson are three different policies for that.
Let's look at each in turn, with the concrete shape of the bug each one fixes.
Debounce — wait until things settle
Debouncing says: "I don't care how many events fire. Just call me once, after the events stop coming for a while."
It's the right pattern when only the final state matters — like a search box where intermediate keystrokes are noise.
function debounce(fn, ms) {
let timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), ms);
};
}
const search = debounce((query) => {
fetch("/api/search?q=" + query).then(/* ... */);
}, 300);
input.addEventListener("input", (e) => search(e.target.value));Walk through it. Each input event calls the wrapped function. The wrapper cancels any pending call and starts a fresh 300ms timer. So if the user types s, o, u, r, the first three timers each get cancelled before they fire. Only the final timer (started after r) actually fires the search — 300ms after the user stopped typing.
The mental model: a debounced function is like a snooze button. Every press resets the timer. The alarm only goes off when you finally stop pressing.
Pick the wait time deliberately. Too short (50ms) and you lose most of the benefit — every user pause between letters fires a search. Too long (1000ms) and the search feels laggy after the user stops typing. For a search box, 200–400ms is the usual sweet spot.
Debounce delays the call. For an action where the user expects an immediate response (clicking a button, hitting Enter), debounce is the wrong pattern — the response feels broken. Use throttle, or no rate-limiting at all.
Throttle — at most once every N milliseconds
Throttling says: "Run now if you haven't run recently, but no more than once every N milliseconds, no matter how many events fire."
It's the right pattern when intermediate events matter — like scroll handling, where you want the page to stay reactive during the scroll, just not at full event rate.
function throttle(fn, ms) {
let lastCall = 0;
return function (...args) {
const now = Date.now();
if (now - lastCall >= ms) {
lastCall = now;
fn.apply(this, args);
}
};
}
const onScroll = throttle(() => {
/* recalc sticky-header layout */
}, 100);
window.addEventListener("scroll", onScroll);The logic: the wrapper remembers when it last called the real function. If the gap is at least ms, it calls and updates the timestamp. Otherwise it does nothing. Events that arrive too soon are simply dropped on the floor.
A throttled scroll handler at 100ms runs at most ten times per second — more than smooth enough for layout updates, ten times less work than the un-throttled version on a fast trackpad.
The analogy: throttling is like the door of a lift. It opens, takes some passengers, and closes for a fixed time before opening again. Anyone who arrives during the closed period waits — but if no one's there when it opens, the lift just leaves empty.
requestAnimationFrame — sync to the screen refresh
The third pattern targets a different question: not "how often should I run this?" but "when is the right moment to run it?"
Browsers paint roughly 60 times per second (more on a 120Hz screen). Anything visual you change with JavaScript only shows up at the next paint. If you change an element's position five times before the next paint, the user only sees the final one — the four earlier writes were wasted work.
requestAnimationFrame(fn) schedules fn to run just before the next paint. The browser bundles all rAF callbacks together and runs them in the same frame, then paints once.
function followCursor(e) {
requestAnimationFrame(() => {
target.style.transform = `translate(${e.clientX}px, ${e.clientY}px)`;
});
}
window.addEventListener("mousemove", followCursor);mousemove can fire 100+ times per second. Without rAF, every fire triggers a layout. With rAF, the writes are coalesced — at most one per frame. The animation is smoother, the CPU does less work, and the result on screen is identical.
A more careful pattern is to guard against scheduling multiple frames at once:
let pending = false;
function followCursor(e) {
if (pending) return;
pending = true;
requestAnimationFrame(() => {
target.style.transform = `translate(${e.clientX}px, ${e.clientY}px)`;
pending = false;
});
}Without the guard, you'd queue a fresh rAF callback per mousemove. The browser would still coalesce the paints (it can only paint once per frame), but you'd be storing a closure for every event. The pending flag means at most one callback is ever queued.
Use rAF for anything that writes to the DOM as a result of a frequent event — scroll, mousemove, drag, animation loops. Use it whether or not you also debounce or throttle. The two are stackable: throttle the work, then rAF the visual update.
Picking the right one
A short cheat sheet for the moment of decision:
- Only the final value matters? → Debounce. Search boxes, autosave-after-typing, validation that runs on input.
- Intermediate values matter, but not at full rate? → Throttle. Scroll-driven layout, resize handlers, sending position updates over a websocket.
- Visual updates tied to a high-frequency event? → requestAnimationFrame. Drag handles, follow-cursor effects, custom scroll-driven animations.
The patterns combine. A drag handler that updates position and sends a network message: rAF the position update (smooth visuals), throttle the network send (fewer requests).
A useful test: can the user tell the difference between every event being handled and only some?
- For typing in a search box: no — they don't care about intermediate states. Debounce.
- For a scroll-driven sticky header: yes, intermediate states matter, but at 60Hz instead of 200Hz they look the same. Throttle.
- For a follow-cursor animation: yes, every frame matters, but only the latest position per frame. rAF.
scroll event. The page hitches because scroll fires 200 times per second on the user's trackpad. The recomputation is fast (1ms) but it triggers layout. Which pattern fits best?requestAnimationFrame but don't add a guard, so every mousemove queues a fresh callback. The animation looks fine. What's the actual cost?