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.

The same 11 ms of main-thread work at 60 Hz and 120 HzWork that fits at 60 Hz misses alternate frames at 120 Hz, halving the effective rate of a main-thread animation.The same 11 ms of main-thread work at 60 Hz and 120 Hz60 Hz frames (16.7 ms)workidleworkidle33.4 ms120 Hz frames (8.3 ms)workmissedworkmissed33.4 msworkidlemissed
Work that fits at 60 Hz misses alternate frames at 120 Hz, halving the effective rate of a main-thread animation.

Step-by-step resolution

Making motion rate-independent and within budgetCorrectness first, then smoothness.Making motion rate-independent and within budget1Search script animation loops for fixed per-tick increments.Finds everything that runs fast at 120 Hz2Rewrite them to compute position from elapsed time.Speed is the same at any rate3Replace simple loops with CSS or element.animate() entirely.The browser owns the timing4Set a main-thread budget of about 4 ms per frame for continuous effects.Leaves headroom at 120 Hz5Move scroll-linked and ambient effects to compositor-driven timelines.They track the display rate for free6Verify at 60 and 120 Hz, and with a capped rate.
Correctness first, then smoothness.

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.

Main-thread headroom left in each frame for 5 ms of per-frame workWhat remains for style, layout, paint and anything else once a 5 ms scroll handler has run.Main-thread headroom left in each frame for 5 ms of per-frame work60 Hz11.7 ms90 Hz6.1 ms120 Hz3.3 ms144 Hz1.9 ms
What remains for style, layout, paint and anything else once a 5 ms scroll handler has run.

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.