Frame-accurate timing in the browser: a 2026 status report
requestAnimationFrame quirks, document.timeline, OffscreenCanvas, WAAPI commitStyles, the new Chromium headless timing model. What is reliable in 2026, and what is still broken.
A short joke I tell at conferences: there are two hard problems in computer science — cache invalidation, naming things, and timing in the browser. The audience usually laughs because they have all hit it. The "off by one frame" bug, the animation that runs at 59.94 fps on Tuesdays, the screenshot that captures the previous state because the compositor has not ticked yet.
This post is a 2026 status report on the timing primitives the browser exposes, which of them you can rely on, and where the holes still are. I am writing this because the situation has actually improved in the last 18 months — there are primitives now that did not exist when we started HyperFrames — and because the holes that remain are different from the holes I would have listed in 2024.
What "frame-accurate" means, precisely
A definition, because the term is used loosely. Frame-accurate timing means: at any wall-clock time t, you can determine exactly which frame should be on screen, and you can capture exactly what that frame contains, with no race condition between the state update and the pixel observation.
This sounds trivial. It is not. Browsers were designed around a soft real-time model where the next paint happens "soon" and might match the current state of the DOM, but might also reflect a slightly older state because the compositor is doing its own thing. For interactive use, this is fine. For frame-accurate capture — what we do at HyperFrames — it is the bug.
Frame accuracy requires four things to align:
- The animation engine reflects the time you set (no stale interpolation).
- The layout engine has computed the new geometry (no stale rects).
- The paint engine has rasterized the new pixels (no stale framebuffer).
- The capture mechanism observes those pixels (no stale screenshot).
A browser tab in 2024 could fail any of these. In 2026, with the right APIs, you can force all four to align. Below is what to use, and what is still broken.
requestAnimationFrame, still the foundation
The grandfather of browser timing primitives. requestAnimationFrame(cb) schedules cb to run before the next paint. The timestamp passed to the callback is the time of the next frame's vsync, not the current time. This sounds backwards but is correct: it lets you compute animations targeting the frame about to be painted.
The 2026 status: requestAnimationFrame works as documented in every browser. The known quirks are stable and well-understood:
- The callback runs before paint, but after style/layout. Reading geometry inside
requestAnimationFrameis safe; mutating it forces a re-layout. - Multiple callbacks scheduled for the same frame run in registration order, all with the same timestamp.
- Background tabs throttle to 1 Hz or stop entirely. Do not rely on it for offscreen work.
The thing that is new in 2026: Chromium's --headless=new mode (the renamed and rebuilt headless renderer) drives requestAnimationFrame at a configurable rate independent of any actual display. You can set it to 60 Hz, 120 Hz, or arbitrary. The old headless mode was tied to a virtual 60 Hz timer that drifted; the new one is locked.
document.timeline and the Web Animations API
The Web Animations API (WAAPI) gives you a global animation timeline you can query and manipulate. document.timeline.currentTime returns the current time on the page's timeline, in milliseconds.
The interesting and underused thing about WAAPI is that animations attached via element.animate() can have their currentTime set directly:
const anim = element.animate(
[{ opacity: 0 }, { opacity: 1 }],
{ duration: 1000, fill: "both" }
);
anim.pause();
anim.currentTime = 500; // jumps to halfway throughThis is the foundation of the seek loop in any frame-accurate renderer. You attach an animation, pause it, and then seek to specific times. The browser interpolates the animated properties at the time you set.
In 2024, currentTime setting was buggy in Chromium — the interpolated value was correct in the computed style, but the painted pixels lagged by one frame. The fix landed in Chromium 124. Since then, setting currentTime and then awaiting the next requestAnimationFrame gives you a state where computed style, layout, and paint are all aligned.
commitStyles() is the API that bakes the current animated state into the element's inline style. Useful for animations with fill: "none" where you want to keep the value after the animation ends, and useful for any pipeline where you want the post-animation state to be readable from the DOM without the animation object alive. We use this in some snapshot tests.
OffscreenCanvas: timing without the DOM
A surprisingly important development for our use case. OffscreenCanvas lets you render canvas content in a Worker, with its own animation loop that does not depend on the main thread's requestAnimationFrame.
For frame-accurate capture, the key property is that an OffscreenCanvas can be driven by a synthetic timeline — you call convertToBlob() or transferToImageBitmap() whenever you want, and the canvas is in exactly the state you last drew. No compositor race conditions.
In 2026 the API supports:
OffscreenCanvas.convertToBlob({ type: "image/png" })— synchronous-feeling capture.- Direct transfer of frames to
VideoEncoder(the WebCodecs path I wrote about in WebCodecs for deterministic video). - Worker isolation, which means the canvas can render while the main thread is doing other work.
For Canvas-heavy compositions (Three.js, custom WebGL, anything with a <canvas> element), the OffscreenCanvas path is the cleanest way to get frame-accurate output. We are slowly migrating the canvas-bearing parts of our showcase to use it.
The Chromium --headless=new timing model
Worth its own section because it is the largest practical change in the headless rendering story since Chromium 108. The old --headless mode had a number of known timing weirdnesses: virtual time that drifted, vsync simulation that was approximate, requestAnimationFrame callbacks that fired at unpredictable real-world rates.
--headless=new (which became the default in Chromium 128) fixes all of these. The behavior, briefly:
- Virtual time is locked to a deterministic clock you control via CDP.
- vsync is simulated at a precise interval you specify.
requestAnimationFrame, animations, transitions, and CSS keyframes all advance in lockstep with virtual time.- Network and disk I/O do not advance virtual time (you can have an "infinite" network round-trip in a single virtual millisecond).
This is the model that makes browser-based deterministic rendering possible. Our entire seek loop (covered in from DOM to MP4) depends on it.
The thing nobody told me when I started using it: virtual time only advances when you tell it to. If you set virtual time to 1000ms and then call setTimeout(fn, 500), the timeout will never fire unless you also advance virtual time past 1500ms. This is the desired behavior, but it confused me for a week the first time.
What is still broken
The honest part of the post. Things that, as of May 2026, I would not yet call "solved."
Font loading and timing. document.fonts.ready resolves when fonts think they have loaded, but in Chromium there is a known race where a font can be reported ready before the glyph cache has been populated for the first render. The bug is one frame of fallback rendering, which is then replaced. The workaround is to render and discard one frame after document.fonts.ready resolves, then start your capture. We do this; it is gross.
WebGL frame timing. WebGL's "frame is done" signal is the implicit one at the end of a requestAnimationFrame callback. There is no explicit "fence" you can wait on. Capturing a WebGL canvas mid-frame is a guaranteed glitch. In practice, you must capture after the rAF callback returns, which means you cannot really capture a WebGL scene that is composed across multiple animation frames. Workarounds exist (the OffscreenCanvas path helps); they are awkward.
Video element timing. <video> elements have their own timeline, separate from document.timeline. Seeking a <video> is asynchronous and has its own ready-state machine. Frame-accurate capture of compositions that embed video is still hard in 2026. The trick we use: rip the video to a sequence of ImageBitmaps ahead of time, render those onto a canvas at each frame. This is a hack but it works.
High-precision audio. Web Audio's AudioContext.currentTime is decoupled from document.timeline. For frame-accurate audio-video sync, you have to manually align them, accounting for the audio context's start latency. The error budget is on the order of a few milliseconds in practice, which is below the audio perception threshold but above what audiophiles would accept.
What we use, in HyperFrames
For completeness, the current state of our timing stack:
- CSS animations: paused, driven by
--hf-timeCSS variable + negativeanimation-delay. The cheapest and most reliable path. Works for ~90% of compositions. - WAAPI animations: paused,
currentTimeset each frame, awaitrequestAnimationFrame. Used for animations that can't be expressed in pure CSS. - JS-driven animation (Three.js, GSAP, custom canvas): the composition listens for
hf-seekevents and updates state. The render harness dispatches the event and awaits the next rAF before capturing. - Virtual time: advanced via CDP
Page.setVirtualTimePolicy. Locks downsetTimeout,requestAnimationFrame, and animation timelines together. - Capture:
Page.captureScreenshotwithcaptureBeyondViewport: falsefor the headless path;OffscreenCanvas.convertToBlobfor the WebCodecs path; both yield bit-identical output given identical input.
The whole stack is documented at length in the developer docs; the architecture overview also lives in the deterministic video manifesto.
Predictions for the next 18 months
A few things I expect to land that would make this post obsolete in good ways.
A standard "capture this canvas, atomically" API. The current convertToBlob is good but does not capture the precise moment the canvas was drawn — only the current state when you ask. A frame-fence API would close the last race condition for canvas-heavy compositions. There is a proposal; I expect it in Chromium 134.
WAAPI scroll-driven animations interoperating with virtual time. Scroll-driven animations (the foundation of the pattern I covered in scroll-driven video) currently don't pause cleanly in the headless render path. There is an open Chromium bug; I expect it fixed in the next two releases.
An official Animation.flushSync in CDP. We patched this into our Chromium build years ago; the upstream proposal has been sitting for 18 months. If it lands, the patch is unnecessary and Chromium is one step closer to a usable rendering API for anyone doing deterministic capture, not just us.
Timing in the browser is, on the whole, a slowly improving story. Each year there are fewer holes. Eventually, I think there will be a vintage of browser where frame-accurate capture is a documented capability rather than a stack of workarounds. We are not there yet. But it is closer than it was, and the year-over-year deltas are real.
In the meantime, the playground is the place to play with this stuff without having to assemble the stack yourself. Render a composition, look at the manifest, see exactly which frame was captured at which virtual time. The boring parts are the ones I am most proud of.
Cite this postBibTeX · APA · Markdown
@misc{tanaka2026frameaccurate,
author = {Kira Tanaka},
title = {Frame-accurate timing in the browser: a 2026 status report},
year = {2026},
url = {https://hyperframes.video/blog/frame-accurate-timing-browser-2026},
note = {HyperFrames blog}
}Kira Tanaka. (2026, May 18). Frame-accurate timing in the browser: a 2026 status report. HyperFrames. https://hyperframes.video/blog/frame-accurate-timing-browser-2026
[Frame-accurate timing in the browser: a 2026 status report](https://hyperframes.video/blog/frame-accurate-timing-browser-2026) — Kira Tanaka, 2026
Kira works on the render core: headless Chromium scheduling, frame capture, and the encoder pipeline. She cares about reproducible builds and small numbers next to the word "variance."
From DOM to MP4: an annotated render
A frame-by-frame walkthrough of what happens between hyperframes render and a finished MP4. Chromium, seek events, capture, encode — the whole pipeline, with timings.
Headless Chrome video rendering, the right way
Headless Chromium is the engine, not the renderer. The difference matters when you're trying to produce frame-perfect MP4s.
WebCodecs for deterministic video rendering in 2026
The WebCodecs API has finally grown up. A deep look at VideoEncoder, hardware H.264 vs AV1 support across Chromium 130+, and why we are slowly rewriting parts of the HyperFrames render path on top of it.
Building with HyperFrames? Come hang out.
We're on GitHub, in Discord, and the playground is one click away. Bring weird ideas — we collect them.