A Puppeteer plugin for capturing page as a video with ultimate quality.
Standard screencast approaches capture frames in real time, producing inconsistent frame timing and
non-reproducible output. puppeteer-capture uses Chrome's
HeadlessExperimental CDP domain
to capture each frame deterministically. If you need frame-perfect, reproducible video output from Puppeteer,
this is the only option.
npm install puppeteer-capture puppeteerimport { capture, launch } from 'puppeteer-capture'
const browser = await launch()
const page = await browser.newPage()
const recorder = await capture(page)
await page.goto('https://example.com', { waitUntil: 'networkidle0' })
await recorder.start('capture.mp4')
await recorder.waitForTimeout(1000)
await recorder.stop()
await recorder.detach()
await browser.close()- Deterministic frame timing — frames are captured on demand via CDP, not in real time
- Frame-perfect reproducible output — the same page produces the same video, every time
- CDP-powered — uses
HeadlessExperimental.beginFramefor precise frame control - Virtual time control —
waitForTimeout()advances the page's own timeline - Works with any Puppeteer workflow — drop-in alongside existing Puppeteer scripts
| puppeteer-capture | puppeteer-screen-recorder | Playwright built-in | |
|---|---|---|---|
| Approach | CDP HeadlessExperimental |
Screencast | Screencast |
| Frame timing | Deterministic | Real-time | Real-time |
| Reproducibility | Frame-perfect | Varies | Varies |
| Time control | Full (virtual clock) | None | None |
| Platform | Linux, Windows | All | All |
Launches a browser configured for deterministic capture.
import { launch } from 'puppeteer-capture'
const browser = await launch()options— OptionalPuppeteerLaunchOptionsfrompuppeteer-core. Theheadlessoption is overridden to'shell'and the required Chrome arguments are appended toargsautomatically.- Returns
Promise<PuppeteerBrowser>
Creates a PuppeteerCapture instance for the given page.
import { capture } from 'puppeteer-capture'
const recorder = await capture(page)page— APuppeteerPageto capture.options— OptionalPuppeteerCaptureOptionsextended with:attach(boolean, default:true) — Whenfalse, the recorder is created without attaching to the page. Callrecorder.attach(page)later.
- Returns
Promise<PuppeteerCapture>
Options passed to capture().
| Option | Type | Default | Description |
|---|---|---|---|
fps |
number |
60 |
Frames per second |
size |
string |
— | Output size in ffmpeg notation (e.g. '1280x720') |
format |
(ffmpeg: FfmpegCommand) => Promise<void> |
PuppeteerCaptureFormat.MP4() |
Output format configurator |
ffmpeg |
string |
— | Path to the ffmpeg binary (overrides auto-detection) |
customFfmpegConfig |
(ffmpeg: FfmpegCommand) => Promise<void> |
— | Additional ffmpeg configuration callback applied after format |
Options passed to recorder.start().
| Option | Type | Default | Description |
|---|---|---|---|
waitForFirstFrame |
boolean |
true |
Whether start() waits for the first frame before resolving |
dropCapturedFrames |
boolean |
false |
When true, frames are emitted via events but not written to the output |
The main interface returned by capture().
| Property | Type | Description |
|---|---|---|
page |
Page | null |
The attached page, or null if detached |
isCapturing |
boolean |
Whether capture is in progress |
captureTimestamp |
number |
Current virtual timestamp (ms) since capture start |
capturedFrames |
number |
Total frames captured since last start() |
dropCapturedFrames |
boolean |
Whether captured frames are dropped (read/write) |
recordedFrames |
number |
Total frames written to the output |
attach(page) — Attaches the recorder to a page. Required only when
capture() was called with { attach: false }.
Throws if already attached.
detach() — Detaches from the current page. Throws if not attached.
start(target, options?) — Starts capturing frames.
target— A file path (string) or aWritablestream.options— OptionalPuppeteerCaptureStartOptions.
When target is a file path, parent directories are created automatically.
stop() — Stops capture and waits for ffmpeg to finalize the output.
waitForTimeout(milliseconds) — Advances the page's virtual timeline by the given number
of milliseconds. Can only be called while capturing. See Time Flow.
on(event, listener) — Subscribes to a capture event.
| Event | Listener Signature | Description |
|---|---|---|
captureStarted |
() => void |
Capture was started |
frameCaptured |
(index: number, timestamp: number, data: Buffer) => void |
A frame was captured |
frameCaptureFailed |
(reason?: any) => void |
Frame capture failed |
frameRecorded |
(index: number, timestamp: number, data: Buffer) => void |
A frame was written to the output |
captureStopped |
() => void |
Capture was stopped |
Format configurators for PuppeteerCaptureOptions.format.
PuppeteerCaptureFormat.MP4(preset?, videoCodec?)
preset— x264 encoding preset (default:'ultrafast'). One of:'ultrafast','superfast','veryfast','faster','fast','medium','slow','slower','veryslow'.videoCodec— Video codec (default:'libx264').- Returns a format configurator function.
import { capture, PuppeteerCaptureFormat } from 'puppeteer-capture'
const recorder = await capture(page, {
format: PuppeteerCaptureFormat.MP4('medium', 'libx264')
})NotChromeHeadlessShell — Thrown when the browser is not chrome-headless-shell.
Use launch() to ensure the correct binary.
MissingRequiredArgs — Thrown when the browser is missing one or more required Chrome
arguments. Use launch() to add them automatically.
The following arguments are required and are added automatically by launch():
--deterministic-mode--enable-begin-frame-control--disable-new-content-rendering-timeout--run-all-compositor-stages-before-draw--disable-threaded-animation--disable-threaded-scrolling--disable-checker-imaging--disable-image-animation-resync--enable-surface-synchronization
This library depends entirely on Chrome's
HeadlessExperimental CDP domain
— specifically the
beginFrame
method — for deterministic frame capture. This is the only mechanism in Chrome that provides compositor-level frame
scheduling, which is what enables frame-perfect, reproducible video output.
beginFrameis not deprecated and remains actively implemented in the Chromium source.enable/disableare marked deprecated in the protocol definition (they are no-ops and have no functional impact).- The domain is labeled Experimental, meaning it can change without notice.
beginFrameis exclusive tochrome-headless-shell(the old headless architecture). It is not available in--headless=new.
| Timeframe | Risk | Rationale |
|---|---|---|
| Near-term (0–12 months) | Low | beginFrame is not deprecated; implementation receives maintenance commits; chrome-headless-shell ships with every Chrome release. |
| Medium-term (1–3 years) | Moderate | The "Experimental" label has persisted since inception without graduating to stable. No public commitment to long-term chrome-headless-shell maintenance exists. |
| Long-term (3+ years) | Moderate–High | Chrome's strategic direction favors --headless=new. If chrome-headless-shell is eventually discontinued, beginFrame goes with it. |
HeadlessExperimental.beginFrame is unique because it controls when the compositor renders each frame. The
alternatives — Page.startScreencast, Page.captureScreenshot, tab capture — all capture frames produced by Chrome's
own compositor timing, which means:
- Frame timing depends on wall-clock time and system load (non-deterministic).
- CSS animations, transitions, and compositor-driven effects cannot be synchronized to a virtual timeline.
- Two runs of the same page may produce different frame counts and visual output.
A partial fallback using Page.captureScreenshot with JavaScript time virtualization can achieve determinism for
JS-driven content but not for CSS animations or compositor-driven effects.
- Remotion and other ecosystem projects also depend on
chrome-headless-shellfor deterministic rendering, creating broader pressure to maintain the binary. - Active Chromium issues (e.g., #40550372 — making BeginFrameControl work with Viz) indicate ongoing investment, not abandonment.
- Chromium's deprecation process typically involves long warning periods and migration paths.
To track changes to this dependency:
- Chromium
HeadlessExperimental.pdl— protocol definition (watch for deprecation annotations onbeginFrame) - Chromium
headless_handler.cc— implementation (watch for removal or functional changes) - Chrome DevTools Protocol changelog
- headless-dev mailing list — discussions on headless mode future
The browser runs in deterministic mode, so the time flow is not real time. To wait for a certain amount of time
within the page's timeline, use PuppeteerCapture.waitForTimeout():
await recorder.waitForTimeout(1000)ffmpeg is resolved in the following order:
FFMPEGenvironment variable pointing to the executable- The executable available via
PATH - Via
ffmpeg-static, if installed as a dependency
See Known Issues for platform constraints and workarounds.
See Contributing for development setup and guidelines.