Skip to content
matthewcm.dev

    ↑ ↓ move · ↵ open · tab scopeopen graph →

    requestAnimationFrame and High Refresh Rates

    rAF runs your code once per display frame, at the screen's refresh rate (60, 120, 144 or 240 Hz). Use delta time, coalesce updates into one frame, and let users cap the frame rate: a 240 Hz screen shouldn't quietly cost 4× the CPU. Don't assume 60 Hz.

    Seedtended planted 5 min read

    🌱 A rough idea, planted recently. Expect rough edges — it will grow.

    On this page

    [!abstract] In one sentence requestAnimationFrame (rAF) runs your callback once, just before the browser paints the next frame, at whatever rate the display refreshes. That makes it the right place for visual updates. It also means a 240 Hz screen does 4× the work of a 60 Hz one unless you design for it.

    What it does

    const id = requestAnimationFrame((now: DOMHighResTimeStamp) => {
      // runs once, before the next paint; `now` is the frame's start time (ms)
    });
    cancelAnimationFrame(id);
    • One-shot. To animate, call it again from inside the callback to make a loop.
    • Synced to the display. It runs once per refresh: 60, 120, 144 or 240 times a second. That’s why rAF animation looks smooth where setInterval(fn, 16) drifts and judders.
    • Paused when hidden. Background tabs and hidden iframes are paused or heavily throttled, so you don’t burn battery on frames nobody sees. Some browsers and power-saving modes also cap it, e.g. to 60 Hz on battery.
    • Per-frame order (more in Browser Fundamentals for Front-end Engineers): input events → rAF callbacks → style → layout → paint → composite. Writes made in rAF land in this frame’s paint.

    The frame budget shrinks as the refresh rate rises

    RefreshFrame budgetRealistic JS budget*
    60 Hz16.7 ms~10 ms
    120 Hz8.3 ms~4–5 ms
    144 Hz6.9 ms~3–4 ms
    240 Hz4.2 ms~1–2 ms

    *The browser needs the rest for style, layout and paint. Work that fits comfortably at 60 Hz drops frames at 240 Hz.

    How to use it for smoother UIs

    1. Animate with delta time, not frame counts. This is the classic bug: x += 2 per frame moves 4× faster on a 240 Hz screen.

    let last = performance.now();
    function loop(now: number) {
      const dt = (now - last) / 1000; // seconds since last frame
      last = now;
      x += speedPxPerSec * dt;        // same speed at 60 or 240 Hz
      el.style.transform = `translateX(${x}px)`;
      rafId = requestAnimationFrame(loop);
    }
    let rafId = requestAnimationFrame(loop);

    2. Coalesce high-frequency input into one render per frame. WebSocket ticks, pointermove and scroll can fire far more often than the screen can show. Buffer the latest state and render it at most once per frame. This is the core of real-time dashboards (Snapshot and Delta Sync over WebSockets, Zustand Transient Updates).

    let pending = false;
    let latest: Tick;
    socket.onmessage = (e) => {
      latest = JSON.parse(e.data);  // cheap: just store it
      if (!pending) {
        pending = true;
        requestAnimationFrame(() => { pending = false; render(latest); });
      }
    };

    3. Batch DOM reads before writes. Reading layout (getBoundingClientRect, offsetWidth) after a write forces a synchronous layout (“layout thrashing”). Do the reads first, then all the writes in rAF.

    4. Prefer CSS and the Web Animations API for simple motion. Transitions and animations on transform and opacity run on the compositor, using no JS per frame, and automatically get the screen’s full refresh rate for free. Use rAF when the frame depends on your own state: canvas, charts, physics, drag.

    5. Clean up. In React, start the loop in useEffect, keep the id in a ref and call cancelAnimationFrame in the cleanup. Otherwise loops leak across re-mounts.

    6. Canvas: scale by devicePixelRatio. On high-DPI screens, an unscaled canvas is blurry. Size the backing store to cssWidth × devicePixelRatio.

    The catch: high refresh rates shouldn’t be a hidden tax

    As a 240 Hz monitor owner, it’s lovely to see a web app use the whole screen: scrolling, drag and charts all feel much better. But:

    • It’s 4× the CPU/GPU work of 60 Hz, with a quarter of the budget per frame. The user pays in fans, battery and heat just for owning a faster screen.
    • Most of the time it adds nothing. A price ticking a few times a second, a progress bar or a dashboard tile doesn’t look better at 240 Hz. The data rate, not the screen, should drive how often you render.
    • Most apps don’t think about it at all. They use defaults (CSS, or a library’s own loop), which is mostly fine. Or they don’t use rAF, and render on every event, which is worse. Few have ever tested above 60 Hz.

    So make the frame rate a user preference for heavy views:

    • A setting: “Animation smoothness: Auto (match display) · 60 fps · 30 fps · Reduced”. Default to Auto for light UIs and a cap for heavy canvases or many live charts.
    • Respect the signals the platform gives you: prefers-reduced-motion (there’s no standard “prefer lower frame rate” query yet), battery saver and hidden tabs.
    • Cap cleanly with rAF. Skip frames by timestamp, and use a divisor of the display rate (240 → 120/60/30). A cap that doesn’t divide evenly (60 fps on 144 Hz) gives uneven frame pacing, i.e. judder.
    const targetMs = 1000 / userFpsCap;   // e.g. 60
    let lastDrawn = 0;
    function loop(now: number) {
      if (now - lastDrawn >= targetMs - 1) { // -1 ms tolerance for timer jitter
        lastDrawn = now;
        draw();
      }
      requestAnimationFrame(loop);
    }
    • Measure the display, don’t guess it. Average a few rAF timestamp deltas to estimate the refresh rate, and send it to RUM alongside dropped frames.

    Don’t assume 60 Hz

    60 Hz was a safe assumption for years and still sits behind a lot of code and team habits. It no longer holds:

    • Phones: 90–120 Hz is now normal on flagships, with adaptive refresh (ProMotion, LTPO).
    • Desktops: 144, 165, 240 and 360 Hz monitors are common among gamers, traders and developers.
    • Variable refresh rate (VRR): the interval can change from frame to frame, which is another reason to always use the real now delta.

    It’s one of a family of “the user’s hardware moved on and the code didn’t” assumptions:

    Assumption that agedToday’s realitySymptom
    60 Hz120–360 Hz, VRRAnimations too fast, or CPU 4× higher
    1× pixelsdevicePixelRatio 2–3Blurry canvas and images
    sRGBDisplay-P3 / HDRWashed-out or clipped brand colours
    Mouse onlyTouch, pen, hybrid pointersHover-only menus, tiny hit targets
    A 1080p rectangleUltra-wide, foldables, notches/safe areasStretched layouts, clipped content
    Light onlyDark mode, forced colours, zoomUnreadable UI

    Where an AI agent helps

    Most devs never see these bugs because they test on their own kit. These are mechanical patterns an agent or a lint rule can catch in review, as a standing rule in the team’s agent skills or review checklist:

    • rAF loops that move things by a fixed amount per frame, with no delta time
    • event handlers that render directly instead of coalescing per frame
    • canvases not scaled by devicePixelRatio
    • rAF loops without cancelAnimationFrame cleanup
    • heavy per-frame work with no fps cap or user setting
    • missing prefers-reduced-motion handling

    See also the Front-end Performance Checklist.

    For tests, inject the clock: drive the loop with fake timestamps at 16.7 ms, 8.3 ms and 4.2 ms intervals, and assert the outcome is the same. CI machines won’t have a 240 Hz screen.

    • #requestanimationframe
    • #animation
    • #performance
    • #refresh-rate
    • #canvas
    • #accessibility
    • #web-development

    Nothing links here yet — it’s a fresh shoot.

    ↪ Links to (4)