skip to content

Several floating panels on a page each position themselves in a scroll listener by reading `getBoundingClientRect()` on their anchor element and then writing `style.transform` on the panel, and scrolling stutters badly. How would you restructure that with `requestAnimationFrame` so the page performs at most one layout per frame?

level: seniorimportance: should knowfreq 40%

answer

  1. one update per frame, not per event
  2. a flag makes the coalescing work
  3. all reads before all writes
  4. the frame callback is where visuals belong
  5. one layout per frame, not per widget

basics

~20 s

Coalesce the scroll events into one requestAnimationFrame callback per frame with a scheduled flag, and inside that callback read every anchor rectangle first and write every transform afterwards. That yields one layout per frame instead of one per panel per event.

solid answer

~50 s

There are two separate problems and both need fixing. First, scroll events fire far more often than the screen updates, so the handler should do no DOM work itself: it sets a `scheduled` flag and, if one is not already pending, calls `requestAnimationFrame`. That coalesces a burst of events into a single update per frame. Second, inside that callback the panels must not each measure-then-write in turn — panel 2's read would force a flush caused by panel 1's write. So run the callback in two explicit phases: collect every anchor's `getBoundingClientRect()` into an array, then apply every `style.transform`. The read phase costs at most one flush, the writes are never observed, and the browser settles them once in its rendering steps. Add `{ passive: true }` to the scroll listener so it never blocks scrolling.

code

javascript · 20 lines
javascript
const pairs = [...document.querySelectorAll('[data-anchor]')].map((panel) => ({
  panel,
  anchor: document.getElementById(panel.dataset.anchor),
}));

let scheduled = false;

function onScroll() {
  if (scheduled) return;
  scheduled = true;
  requestAnimationFrame(() => {
    scheduled = false;
    const rects = pairs.map(({ anchor }) => anchor.getBoundingClientRect()); // read phase
    pairs.forEach(({ panel }, i) => {                                        // write phase
      panel.style.transform = `translate(${rects[i].left}px, ${rects[i].bottom}px)`;
    });
  });
}

window.addEventListener('scroll', onScroll, { passive: true });

go deeper

for a junior

Know that scroll events fire far more often than the screen updates, and that visual updates belong in a requestAnimationFrame callback rather than directly in the handler.

for a middle

Explain the two separate fixes and why both are needed: coalescing many events into one callback per frame, and ordering all measurements before all mutations inside that callback.

for a senior

Walk the whole restructure end to end, including the guard flag and teardown, and explain why the batched version costs one layout per frame no matter how many panels exist.

for a principal

Frame it as a shared-resource problem: independent widgets each measuring on scroll multiply a per-frame budget nobody owns, so the answer is one coordinated scheduler rather than a rule each team remembers.

## What is actually wrong with the naive version The handler as written has two independent defects that compound. **Too often.** Scroll events can be dispatched many times between two screen updates. Every one of those runs the full positioning work, and every intermediate result is thrown away — the user only ever sees the last one before the frame is drawn. **Wrong order.** Each panel reads its anchor's rectangle and then writes its own transform. With several panels, that becomes read, write, read, write. The write leaves style and layout dirty; the next panel's `getBoundingClientRect()` is contractually obliged to return a current rectangle, so the browser flushes synchronously before answering. N panels produce N flushes — per event, not per frame. ## Step one: one update per frame ```js let scheduled = false; function onScroll() { if (scheduled) return; scheduled = true; requestAnimationFrame(() => { scheduled = false; update(); }); } window.addEventListener('scroll', onScroll, { passive: true }); ``` The flag is the important part. Without it, a burst of ten scroll events queues ten callbacks and you have coalesced nothing. With it, the listener is a couple of instructions and all the real work happens once, in the frame callback. Using `requestAnimationFrame` rather than a timer matters here because visual updates should be tied to when the browser is about to render, not to an arbitrary delay that can land at any point relative to the frame. ## Step two: measure everything, then mutate everything ```js function update() { const rects = pairs.map(({ anchor }) => anchor.getBoundingClientRect()); // reads pairs.forEach(({ panel }, i) => { // writes panel.style.transform = `translate(${rects[i].left}px, ${rects[i].bottom}px)`; }); } ``` Now the first read pays for at most one flush, every remaining read runs against clean layout, and no write is ever observed before the browser's own rendering steps. The layout count per frame is one, no matter how many panels there are. Note the discipline this imposes: the measurement and the mutation for one widget are no longer adjacent in the code. That is the point, and it is why a well-meaning helper like `positionPanel(panel)` — which measures and writes for a single widget — quietly reintroduces the problem the moment a caller loops over it. The phase split has to hold across components, not just inside one function. ## Why reads must come before writes even for transform It is tempting to assume that writing `style.transform` is harmless because transforms are cheap to render. Do not rely on that for the ordering rule: `getBoundingClientRect()` reports the element's transformed, viewport-relative box, so after any style write the browser still has to bring style up to date before it can answer, and it will run layout too if layout is dirty. The safe rule is unconditional — **all reads, then all writes** — because it does not depend on knowing which properties invalidate what. ## Pitfalls worth naming - **No guard flag.** Scheduling a callback per event coalesces nothing. - **Forgetting to clear the flag** inside the callback, so no further updates are ever scheduled. - **Cancelling on teardown.** Keep the handle from `requestAnimationFrame` and pass it to `cancelAnimationFrame` when the widget is destroyed, or the callback runs against detached nodes. - **A non-passive scroll listener.** Declaring `{ passive: true }` tells the browser the handler will not call `preventDefault()`, so it need not wait on it. - **Measuring inside the write phase.** Any read that sneaks in after the first write puts the second flush back. ## When one layout per frame is not achievable Some effects genuinely need to measure the result of a change — for example writing a size, then measuring how the content wrapped. That costs a second flush by definition. The honest options are to accept exactly one extra flush and do it deliberately, or to defer the follow-up measurement to the next frame so the browser's own layout pass produces the value you need. What you must not do is let that two-phase dance repeat per widget.

  • Why not just debounce the scroll handler with setTimeout instead?
    A timer is not aligned to the browser's rendering, so the update can land at any point relative to the frame and the panels visibly lag the content by a frame or more. It also does nothing about the second defect: if the debounced callback still measures and writes per panel, it thrashes exactly as before, just less often.
  • What happens to the writes made inside the animation-frame callback?
    They mark style and layout dirty like any other write, and the browser settles them in the rendering steps it runs immediately after the frame callbacks — so they are applied in that same frame with no extra flush. That only holds while nobody reads geometry after writing inside the callback; a read there forces the layout again before rendering.
  • One widget genuinely needs to measure itself after being repositioned. How do you handle that?
    Accept one deliberate extra flush, or defer the follow-up measurement to the next frame so the browser's own layout pass supplies the value. Either way, do it once for the whole set rather than per widget: schedule a single follow-up frame that measures everything that asked for it, so the extra cost does not scale with widget count.

saying these in an interview costs you the question

  • Claims requestAnimationFrame moves the work off the main thread
  • Thinks a passive listener prevents layout thrashing
  • Schedules a frame callback per scroll event with no guard
  • Keeps measure-then-write adjacent per widget and calls it batched
  • Never cancels the pending callback when the widget is torn down

context