On a 60 Hz display, the work inside a requestAnimationFrame callback consistently takes about 25 ms. What frame rate does the user actually see, and why is it not roughly 40 fps?
answer
- the display presents only at refresh boundaries
- miss the deadline, wait for the next
- rates land on 60, 30, 20
- the budget covers the whole pipeline
- your callback is one of many
basics
~20 sRoughly 30 fps, not 40. The display only presents at its refresh boundaries, so a frame that misses one deadline waits for the next: effective rates fall to submultiples of the refresh rate — 60, then 30, then 20 — rather than degrading smoothly.
solid answer
~50 sA 60 Hz display accepts a new frame only every 16.7 ms. If the browser cannot have a frame ready by a given boundary it does not present it late; the previous frame stays on screen and the new one waits for the following boundary. So 25 ms of work needs two intervals, giving a new frame every 33.3 ms — about 30 fps — and the 8 ms of slack is idle. The rate quantises to 60, 30, 20, 15 rather than sliding smoothly. Worse, the 16.7 ms is not your callback's budget: it has to cover style recalculation, layout, paint and compositing for the frame, plus every other callback registered for it, so the callback's share is a good deal smaller. Fixing this means doing less per frame — spreading work across frames, moving computation off the main thread, or shrinking what has to be laid out and painted.
go deeper
Know that a frame has a deadline of about 16.7 ms on a 60 Hz screen and that missing it means the user sees fewer frames per second, not a slightly later one.
Explain the quantisation: the display presents only at refresh boundaries, so a missed deadline waits for the next and the rate drops to a submultiple — 30, then 20. State that the budget covers the whole rendering update, not just your callback.
Separate script cost from pipeline cost before acting, and choose the right remedy — fewer items per frame, work moved to a worker, or a cheaper style and layout pass. Know why adding more callbacks changes nothing and why an unstable rate looks worse than a stable lower one.
Own the tradeoff between per-frame fidelity and completion time for the product as a whole, decide where the line sits between main-thread and worker work, and set the expectation that frame cost is a shared budget no single feature may consume.
## Frames are presented on a schedule, not when they are ready A display refreshes at fixed instants. On a 60 Hz panel that is every 16.7 ms. The browser's compositor hands a finished frame to the display for one of those instants; if the frame is not finished in time, the instant passes with the previous frame still on screen, and the new one is presented at the *next* instant. Nothing shows a half-finished frame, and nothing shows a frame between refreshes. That quantisation is why the answer is 30 and not 40. Work taking 25 ms cannot fit in one 16.7 ms interval, so it spans two: a frame appears every 33.3 ms, which is 30 fps, and roughly 8 ms of each pair of intervals is spent waiting. Push the work to 40 ms and you need three intervals — 20 fps. The achievable rates on a fixed-refresh display are 60, 30, 20, 15, and so on, so a small overrun costs a large, visible drop. This is also why an animation that is *just* over budget feels dramatically worse than one that is just under: the user does not see 55 fps, they see 30. (Displays with variable refresh can present at intermediate intervals, which softens the cliff; the fixed-refresh model above is the one to reason from, and the one that matches most machines.) ## The 16.7 ms is not your callback's budget The interval has to accommodate everything the browser does to produce the frame: - the frame-tied steps that precede your callback (resize and scroll steps, media query evaluation, advancing declarative animations), - **every** animation frame callback registered for this frame, not just yours, - observer callbacks tied to the rendering update, - style recalculation, layout, paint, - compositing and hand-off. And all of that competes with ordinary tasks on the same main thread. A rAF callback has no priority and no thread of its own: if a long task is running when the deadline arrives, the entire rendering update is late, your callback included. So the honest way to state the budget is that everything for the frame must fit in the interval, and your callback should be aiming at a few milliseconds, not sixteen. ## Why splitting the callback does not help A common wrong move is to split the 25 ms across two registered callbacks. Both run in the same frame, so the frame still takes 25 ms. Genuine fixes change how much work each *frame* does: - **Do less per frame.** Update only what changed; skip work whose result is not visible; cap how many items are touched in one frame and continue on the next. - **Spread across frames.** Split a batch so each frame handles a slice. This trades total completion time for a responsive frame rate — often the right trade for anything the user is watching. - **Move it off the main thread.** Pure computation — parsing, layout maths for a large data set, image processing — can run in a worker and post results back, leaving the callback to apply them. - **Shrink the pipeline cost, not just the script.** If the 25 ms is mostly style and layout rather than your JavaScript, reducing the number and complexity of the elements being recalculated is the lever, not micro-optimising the callback. ## How to tell where the 25 ms went The callback's own duration is measurable directly — read `performance.now()` at entry and exit, or wrap the work in `performance.mark` and `performance.measure`. That distinguishes "my JavaScript is slow" from "my JavaScript is fine but the frame it triggers is expensive to render". Those two have completely different remedies, and guessing between them is the most common way to spend a day optimising the wrong thing. ## The stable-versus-variable distinction A consistent 25 ms produces a steady 30 fps, which looks like a low-quality but coherent animation. Work that oscillates around the 16.7 ms boundary is worse to watch: the presentation rate flips between 60 and 30, and the irregular cadence reads as stutter even though the average is better. If you cannot get reliably under the deadline, a deliberately halved update rate can look better than an unstable one — being consistently late is more watchable than being unpredictably late.
- Does splitting the 25 ms of work across two registered rAF callbacks improve the frame rate?No. Every callback registered for a frame runs in that same frame, one after another, so the frame still costs 25 ms. Only doing less work per frame helps — spreading it across successive frames, moving computation to a worker, or reducing what has to be styled, laid out and painted.
- How would you tell whether the 25 ms is your JavaScript or the rendering work it causes?Time the callback itself — `performance.now()` at entry and exit, or a `performance.mark`/`performance.measure` pair. If the callback is 3 ms and the frame is still 25 ms, the cost is in style, layout, paint or compositing, and shrinking the script would gain almost nothing.
- Would the same 25 ms of work behave differently on a 120 Hz display?The wall-clock rate ends up similar, but the quantisation is finer: intervals are 8.3 ms, so 25 ms spans four of them, presenting roughly every 33 ms. The visible penalty is comparable, while the headroom for staying at full rate is half what it was — high-refresh hardware makes an overrun easier to hit, not easier to hide.
saying these in an interview costs you the question
- Divides 1000 by 25 and answers 40 fps
- Treats the full 16.7 ms as the callback's own budget
- Thinks splitting into two callbacks halves the frame cost
- Assumes rAF callbacks get priority over other main-thread work
- Optimises the script when the cost is in layout and paint