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.
Headless Chromium is the only piece of infrastructure that can render arbitrary HTML at arbitrary resolutions, at production quality, with the same engine that ships in the browser your users see. That's the good news. The bad news is that "headless Chromium" is the engine, not the renderer — and most teams that reach for it for video work treat it like the latter.
The mistake
The instinct is: launch Chromium, navigate to the page, start a screencast, wait, stop. You get a .webm. Convert to MP4. Ship.
This sort-of works for short, simple animations. It falls apart the moment any of the following happen:
- The animation is more expensive than 60fps can sustain in real time.
- You need exact frame counts (e.g., for a 30-second video at 30fps, exactly 900 frames).
- The render needs to be reproducible across CI runs.
- You want resolutions higher than the host display.
Screencast captures real-time playback. The wall clock controls everything. CPU pressure drops frames. Two runs differ. You're hosed.
The right model
Headless Chromium is a deterministic DOM engine if you drive it correctly. The trick is to treat it like a function, not a stream:
- Load the page once.
- Pause everything. Set
Page.setBypassCSP, setAnimation.setPlaybackRateto 0, intercept anyrequestAnimationFrameyour page uses. - For each frame index
i:- Set the page's logical time to
t = i / fps. - Trigger your render function (
window.render(t)or dispatch ahf-seekevent). - Wait for the resulting paint to settle. This is the tricky bit —
Page.captureScreenshotwill happily snapshot mid-paint if you don't wait. - Screenshot.
- Set the page's logical time to
The captured PNGs feed an MP4 muxer (h264-mp4-encoder, mp4-muxer, ffmpeg — your call).
Settling the paint
The single most-common mistake: capturing before the paint has settled. Three reliable approaches, in order of robustness:
Page.captureScreenshotwithcaptureBeyondViewport: falseafter arequestAnimationFramefollowed by arequestAnimationFrame. Two RAFs is the smallest reliable interval to ensure a layout-paint cycle finishes.- Hook into the Compositor. CDP exposes
AnimationandLayerTreedomains. Wait forLayerTree.layerPainted. - Force a font/image preload. Block render until
document.fonts.readyand all<img>elements havedecodedresolved.
For most pages, two RAFs is enough. For pages that load fonts mid-animation, you also need the third.
Resolution and DPR
Headless Chromium renders at the viewport size you give it. For a 1920×1080 video at "retina" quality (oversample then downscale), set the viewport to 1920×1080 with deviceScaleFactor: 2, then downscale during encode. The output is crisper than rendering at 1920×1080 with DPR 1.
Don't set the viewport to 3840×2160 directly unless you actually want a 4K video — the screenshots get expensive fast.
The performance ceiling
The expensive step is Page.captureScreenshot. At 1080p, it averages 80-150ms per frame on a modern Chromium. That's your throughput ceiling: a 60-second video at 30fps is 1800 frames × 100ms = 3 minutes minimum, plus encode. Parallelizing across multiple browser contexts gets you close to linear speedup until disk I/O for the PNGs becomes the bottleneck.
For batch work, render to in-memory framebuffers and pipe directly to the encoder — skipping the disk round-trip is a 2-3× speedup.
Resource management
A long-lived Chromium process accumulates memory. For batch rendering (thousands of videos), restart Chromium every N renders — pick N such that each restart is amortized but RSS doesn't grow unbounded. We've found N = 50 works for 1080p, lower for 4K.
The deeper writeup of why this all matters is in from DOM to MP4. The "why not screencast" case is in Puppeteer video recording.
A working summary
Headless Chromium is the right engine. The wrong way to use it is as a screen recorder; the right way is as a function that turns (html, t) into pixels. Once you've built the loop around that function, everything else — CI, caching, batch, A/B — is small infrastructure work.
Cite this postBibTeX · APA · Markdown
@misc{tanaka2026headless,
author = {Kira Tanaka},
title = {Headless Chrome video rendering, the right way},
year = {2026},
url = {https://hyperframes.video/blog/headless-chrome-video-rendering},
note = {HyperFrames blog}
}Kira Tanaka. (2026, May 19). Headless Chrome video rendering, the right way. HyperFrames. https://hyperframes.video/blog/headless-chrome-video-rendering
[Headless Chrome video rendering, the right way](https://hyperframes.video/blog/headless-chrome-video-rendering) — 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."
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.
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.
Programmatic video generation in Node.js
A walkthrough of generating MP4s from a Node.js process — headless Chromium, frame capture, encoding, and the pitfalls to avoid.
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.