Under the Hood
Frontend

Batching DOM writes with requestAnimationFrame

A `pointermove` listener can fire a dozen times before the screen ever repaints, and a handler that writes to the DOM on every one of those events throws away all but the last write. This lesson covers the ref-plus-rAF coalescing pattern that guarantees exactly one DOM write per frame, the guard flag that makes the "at most one" part actually true, and why tying writes to the paint schedule beats guessing a timeout interval.

Batching DOM writes with requestAnimationFrame

Drag a slider and watch a tooltip trail your cursor. Move the mouse fast, and your pointermove handler might fire fifteen or twenty times in the time it takes the screen to draw a single new frame. If that handler writes to the DOM — updates a style.transform, sets textContent, anything the browser has to lay out or paint — it just did that work fifteen times to produce one visible frame. Fourteen of those writes were completely wasted: the user's eyeballs only ever see whatever was on screen at the moment of the last repaint before the display refreshed.

This isn't a bug in your code, it's a mismatch of rates. Input devices, browsers, and displays don't share a single clock, and once you see why, the fix is mechanical: never write to the DOM from inside the raw event handler. Stash the value, and let the browser tell you when it's actually about to draw.

Why events outrun frames

A display has a fixed refresh rate — 60Hz is the common baseline, so a new frame can appear on screen at most once every ~16.7ms. Input doesn't respect that boundary at all. A mouse polls at whatever rate its firmware and OS driver agree on, commonly 125Hz to 1000Hz, and gaming mice go well past that — a 240Hz mouse can report four position updates in the time your screen shows one frame. Trackpads, touchscreens, and scroll wheels behave similarly: they generate pointermove, touchmove, scroll, and resize events on their own schedule, not the display's.

Even at ordinary polling rates, the browser's main thread doesn't hand you those events instantly. It's busy running other JavaScript, doing layout, or painting, so several input events can genuinely queue up and get delivered back-to-back in a single turn of the event loop, all before the browser gets a chance to paint anything. Either way — high-frequency hardware or a busy main thread — the effect is the same: your handler runs more often than the screen updates.

If your handler does real DOM work synchronously on every one of those calls, you pay for N writes but the display only ever shows the state from the Nth write. The first N-1 were pure overhead: forced style recalculation, maybe layout, maybe paint, for pixels nobody ever saw. On a busy frame with a heavy layout, that overhead is exactly what pushes you past your frame budget and into visible jank — the topic of the very next lesson, Layout thrashing: the forced synchronous layout trap.

The fix: decouple "record" from "render"

The pattern splits every update into two jobs that run at two different rates:

  1. On every raw event — do the cheapest possible thing: write the new value into a ref. A ref write touches a plain JS object, not the DOM. No re-render, no layout, no paint. It can happen twenty times a frame for free.
  2. Once per frame, at most — schedule a requestAnimationFrame callback that reads whatever value is currently sitting in the ref and performs the one real DOM write. Because rAF callbacks run right before the browser's next paint, that single write reflects the freshest data available, and it lands exactly when the browser is already about to draw.

The ref is the hand-off point. The event handler never talks to the DOM; the rAF callback never listens for events. Each one only has to do the part it's good at.

The guard flag: making "at most one" true

Splitting the work isn't enough by itself — if you called requestAnimationFrame from inside the handler on every event, you'd schedule a new callback every time, and you'd be back to one DOM write per input event, just each one delayed by a frame. The thing that actually caps you at one write per frame is a guard.

The guard is a ref — call it rafId — that starts as null. It answers one question: is a frame callback already scheduled?

  • On each input event: if rafId is already set, a callback is already booked to run before the next paint, and it will pick up whatever the latest value in the value-ref is when it runs. There's nothing more to do, so do nothing.
  • If rafId is null, no callback is booked yet. Call requestAnimationFrame, store the returned id in rafId, and inside that callback do the real write, then reset rafId back to null. Resetting it is what lets the next input event schedule a fresh frame once this one has actually happened.

Skip that guard check and the whole thing degrades silently — it still "works" in the sense that the DOM eventually shows the right value, but you've lost the entire point: you're scheduling and running a full rAF callback per event again, each one doing a real write, instead of coalescing them into one.

Here's the complete pattern, tracking a pointer's x-position onto a tooltip's transform:

function useCoalescedPointerTracking(elementRef) {
  const latestX = useRef(0);
  const rafId = useRef(null);

  const handlePointerMove = useCallback((event) => {
    // Cheapest possible thing: stash the value. No DOM touched.
    latestX.current = event.clientX;

    if (rafId.current !== null) {
      // A frame callback is already booked — it'll read the fresh
      // value above when it runs. Nothing else to schedule.
      return;
    }

    rafId.current = requestAnimationFrame(() => {
      // Runs once, right before the next paint. Read the LATEST
      // value, not whatever was current when this was scheduled.
      const el = elementRef.current;
      if (el) {
        el.style.transform = `translateX(${latestX.current}px)`;
      }
      // Free the guard so the next pointermove can book a new frame.
      rafId.current = null;
    });
  }, [elementRef]);

  useEffect(() => {
    return () => {
      if (rafId.current !== null) cancelAnimationFrame(rafId.current);
    };
  }, []);

  return handlePointerMove;
}

Fifteen pointermove events between two paints produce fifteen cheap ref writes and exactly one style.transform write, and that one write always uses the freshest coordinate available.

This is precisely the mechanism behind the drag demo in Refs vs. state: why the hot path skips setState — that lesson's live component is this ref-plus-rAF pattern wired up to a real drag interaction, and it's worth going back to as the fully worked example. This lesson is about the scheduling mechanics underneath it: why the guard exists and what breaks without it.

Why not just throttle with setTimeout?

A time-based throttle — "run the handler at most once every 16ms" — looks like the same idea, but it's guessing at a schedule the browser already knows exactly. Two ways that guess goes wrong:

It can fire when there's nothing to show. If the tab is backgrounded, or the browser skipped a frame because the previous one ran long, a setTimeout-based throttle doesn't know that — it fires on its own clock regardless of whether the browser is actually about to paint. You do a DOM write that gets computed and then discarded unseen, the exact waste this pattern exists to avoid.

It can fire too late relative to the paint it was aiming for. setTimeout's delay is a minimum, not a guarantee — the callback runs whenever the event loop gets around to it, which can land after the frame boundary it was meant to feed. requestAnimationFrame, in contrast, is scheduled by the browser specifically to run right before it paints. There's no interval to tune and no risk of drifting relative to the actual refresh — the callback's timing is defined in terms of the thing you care about (the next paint) rather than a fixed number of milliseconds you're hoping lines up with it.

Go deeper

Check yourself

Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.

  1. A 240Hz mouse is being used on a 60Hz monitor. Roughly how many pointermove events can fire per displayed frame, and why doesn't the extra precision help if you write to the DOM on every event?
  2. Walk through what the rafId guard ref contains at each step: right after mount, immediately after the first pointermove, during the second through fifth pointermove of the same frame, and immediately after the scheduled callback finishes running.
  3. If you deleted the `if (rafId.current !== null) return;` check but kept everything else, what would actually change about how many DOM writes happen per frame — and why would it stop capping at one?
  4. Why is it correct for the rAF callback to read `latestX.current` at the moment it runs, rather than capturing the x-value from whichever pointermove event triggered the `requestAnimationFrame` call?
  5. A setTimeout-based throttle fires every 16ms regardless of tab state. Describe a concrete scenario where this produces a DOM write that the user never sees, and explain why an rAF-based version wouldn't have made that same write.
  6. Why does writing the new value into a ref on every pointermove event not trigger a re-render or layout, when writing it into React state would?
  7. If the element being updated is removed from the DOM mid-drag, what goes wrong in the callback above, and where does the code guard against it?