PerformancePublished 12/12/2024

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:

js
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:

  1. Debounce: wait until the user stops, then act.
  2. Throttle: act at most once per time interval.
  3. 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.

js
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:

js
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:

js
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:

js
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:

js
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.

ScenarioRecommendationWhy
Search box input, autocompleteDebounceYou only care what the user finally typed
Live form validationDebounceWait until the user finishes a field, then validate
Recalculating layout after resizeDebounceYou only need the final size, not the sizes along the way
Autosaving draftsDebounceSave after the user pauses, not on every keystroke
Scroll progress bar, infinite scrollThrottleYou need continuous feedback, but not on every event
Sending coordinates while dragging or tracking the mouseThrottleYou need continuous updates, but with a capped rate
Preventing double clicksLeadingThe 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: setTimeout gets 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:

  1. 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.
  2. It runs at most once per frame: no work that gets computed but never seen.
  3. 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:

js
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:

  1. Every scroll event does the lightest possible thing: it stores the latest position.
  2. If no rAF has been scheduled for this frame yet, it schedules one. If one has, it skips.
  3. Before the next frame, update is 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:

js
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:

js
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:

js
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 scroll handler. 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.