Animation Budgets on High-Refresh-Rate Displays
Part of Frame Budgeting & 16ms Targets in Performance Budgeting & GPU Architecture.
The problem
The 16.67ms budget is a 60 Hz number. A growing share of phones, tablets, laptops and monitors refresh at 90, 120 or 144 Hz, and on them two different things go wrong.
Some animations run too fast. A carousel that moves 4px per requestAnimationFrame tick crosses the screen twice as quickly on a 120 Hz phone. A physics loop that assumes a fixed 16ms step makes a bouncing element feel frantic.
Other animations look worse than at 60 Hz. A scroll-linked effect that fitted comfortably in 16ms now misses every other frame at 120 Hz, and because the surrounding compositor-driven scrolling is perfectly smooth, the stutter is more visible than it was on a 60 Hz screen where everything was equally coarse.
Root cause analysis: the frame is half as long, and not everything knows
At 120 Hz a frame lasts 8.33ms. The compositor, which composites layers and runs compositor-driven animations, generally keeps up at the display rate. The main thread has the same workload as before and half the time to do it in, per frame.
requestAnimationFrame fires at the rate the browser renders the page, which on a high-refresh display is typically the display rate. Any code that treats one callback as a fixed unit of time is therefore wrong by a factor of two. CSS animations, transitions and the Web Animations API are all time-based, so they play at the declared duration everywhere; they simply produce more intermediate frames.
The rendering rate is not always the display rate. Browsers and operating systems cap page rendering in low-power modes, when a laptop is on battery, for background or cross-origin iframes, and in some browsers by default until a user setting changes it. Code has to be correct at any rate, not tuned for 60 or 120.
Step-by-step resolution
A useful working budget for continuous main-thread work — anything that runs every frame while scrolling or during a long animation — is about 4ms, half of the 120 Hz frame. One-off work at the start of an interaction can still take longer; it costs a dropped frame or two, not a sustained stutter. The breakdown of the rest of the frame is in the parent guide on frame budgeting.
Production code pattern
// Before: speed depends on the rendering rate.
function tickBroken() {
x += 4; // 240 px/s at 60 Hz, 480 px/s at 120 Hz
el.style.translate = `${x}px 0`;
requestAnimationFrame(tickBroken);
}
// After: speed is defined in pixels per second.
const SPEED = 240; // px per second, at any frame rate
let last;
function tick(now) {
if (last !== undefined) {
const dt = Math.min(now - last, 50); // clamp gaps from hidden tabs or long tasks
x = (x + SPEED * dt / 1000) % trackWidth;
el.style.translate = `${x}px 0`;
}
last = now;
raf = requestAnimationFrame(tick);
}
// Better still for a simple loop: let the browser time it on the compositor.
const marquee = el.animate(
[{ translate: '0 0' }, { translate: `${-trackWidth}px 0` }],
{ duration: trackWidth / SPEED * 1000, iterations: Infinity, easing: 'linear' }
);
const reduce = matchMedia('(prefers-reduced-motion: reduce)');
if (reduce.matches) marquee.pause(); // static strip, no continuous motion
/* Scroll-linked effect: compositor-driven, so it keeps the display rate. */
.header {
animation: shrink linear both;
animation-timeline: scroll(root);
animation-range: 0 120px;
}
@keyframes shrink { to { scale: 0.92; } }
@media (prefers-reduced-motion: reduce) {
.header { animation: none; }
}
Rendering Impact: composite for the Web Animations marquee and the scroll-driven header; main-thread per frame for the rewritten rAF loop. Only the last one is subject to the 8.3ms squeeze, which is why it should be the fallback rather than the default.
The clamp on dt matters more at high refresh rates, not less. A 60ms long task at 120 Hz swallows seven frames; without the clamp the next tick jumps the element by the full 60ms of travel at once.
Testing without every display
You do not need a 144 Hz monitor to catch most problems. Frame-count bugs appear at any non-60 rate, so a laptop with a 90 or 120 Hz panel, or a recent phone connected to remote debugging, is enough. The Performance panel’s Frames track shows the actual presentation interval; confirm it reads around 8.3ms before trusting a trace.
To simulate a different rate in automated tests, wrap requestAnimationFrame so it advances a fake clock by 8.33ms per call, and assert that an element reaches the same position after one simulated second as it does with 16.67ms steps. That test catches every per-tick increment without real hardware.
For budget problems, CPU throttling in the Performance panel is a reasonable proxy: a 2x slowdown at 60 Hz leaves roughly the headroom a real 120 Hz frame does. If an effect stays smooth at 2x throttling, it will usually stay smooth at 120 Hz on the same machine.
Verification checklist
Constraints and trade-offs
- Higher rates multiply compositor work too; very large or numerous layers can drop frames on the GPU side even with an idle main thread.
- Battery-saving modes often cap rendering at 60 Hz or lower; optimise for correctness at any rate rather than for peak rate.
- Some effects intentionally step — a sprite-sheet animation using
steps()— and should stay tied to their own timing, not the display rate. - Canvas and WebGL loops rendering at 120 Hz double their GPU cost; consider rendering at half rate when the scene is simple.
- Throttling as a proxy is approximate: it slows the CPU, not the display, so compositor behaviour differs.
Frequently asked questions
Do CSS animations run at 120 fps automatically?
They are time-based, so they play at the declared duration, and compositor-driven ones are generally updated at the rate the browser renders, which on a high-refresh display is often the display rate. Main-thread animations only reach that rate if the main thread can keep up.
Why does my animation run twice as fast on some phones?
The script advances the animation by a fixed amount per requestAnimationFrame callback. On a 120 Hz display callbacks arrive twice as often. Compute movement from elapsed time instead.
What frame budget should I design for?
Design main-thread work for the 8.3ms frame of a 120 Hz display, and keep continuous per-frame work to about half of that. Anything that fits there fits comfortably at 60 Hz.
Can I force a page to render at 60 Hz to save work?
Not directly for the whole page. You can throttle your own script loops by skipping callbacks based on elapsed time, but compositor-driven animations and scrolling follow the browser’s rate.
Related
- Frame Budgeting & 16ms Targets — the parent guide on decomposing the frame
- Budgeting Concurrent Animations per Frame — sharing the shorter frame between effects
- Main-Thread Scheduling & Long Animation Frames — keeping script out of the frame