Sadly, it is so. For Puppeteer v23+, the plugin enforces use
of the chrome-headless-shell binary.
117.0.5938.88(default forpuppeteerversion(s)21.3.0): reacts withtargetCrashed117.0.5938.92(default forpuppeteerversion(s)21.3.2...21.3.6): reacts withtargetCrashed117.0.5938.149(default forpuppeteerversion(s)21.3.7...21.3.8): reacts withtargetCrashed118.0.5993.70(default forpuppeteerversion(s)21.4.0...21.4.1): reacts withtargetCrashed119.0.6045.105(default forpuppeteerversion(s)21.5.0...21.7.0): reacts withtargetCrashed120.0.6099.109(default forpuppeteerversion(s)21.8.0): reacts withtargetCrashed
Unfortunately, it is so.
This relates to timers, animations, clicks, etc. To process interaction with the page, frame requests have to be submitted and thus capturing have to be active.
The exact origin of the issue is not yet known, yet it's likely to be related to the deterministic mode.
Calling page.setViewport() before starting the capture behaves the same, yet calling it after starting the capture
works yet not always. Thus it's safe to assume that there's some sort of race condition, since adding
recorder.waitForTimeout(100) just before setting the viewport workarounds the issue.
Also it should be taken into account that since frame size is going to change over the time of the recording, frame size autodetection will fail. To workaround this issue, frame size have to be specified:
const recorder = await capture(page, {
size: `${viewportWidth}x${viewportHeight}`,
})
await recorder.start('capture.mp4', { waitForFirstFrame: false })
await recorder.waitForTimeout(100)
await page.setViewport({
width: viewportWidth,
height: viewportHeight,
deviceScaleFactor: 1.0,
})A friendlier workaround is enabled by default: recorder.start() automatically waits for the first frame to be
captured. This approach seems to allow bypassing the alleged race condition:
const recorder = await capture(page, {
size: `${viewportWidth}x${viewportHeight}`,
})
await recorder.start('capture.mp4')
await page.setViewport({
width: viewportWidth,
height: viewportHeight,
deviceScaleFactor: 1.0,
})The following functions have to be overridden with injected versions:
setTimeout&clearTimeoutsetInterval&clearIntervalrequestAnimationFrame&cancelAnimationFrameDate()&Date.now()performance.now()
The injection should happen before page content loads:
const recorder = await capture(page) // Injection happens here during attach()
await page.goto('https://google.com') // Possible capture would happen here, thus injected versions would be capturedChrome's media engine operates below the JavaScript and compositor layers that puppeteer-capture controls.
The virtual time injection affects JS timing APIs, and HeadlessExperimental.beginFrame advances the
compositor clock, but neither mechanism controls the internal media decoder/renderer. As a result,
<video> and <audio> elements appear frozen during capture even though JavaScript-observable properties
like currentTime may advance.
This also applies to animated GIFs, which use a separate image animation pipeline.
Research into Chrome's architecture (#191) confirmed this is a fundamental gap:
- The CDP
Mediadomain is observation-only — it monitors player state but has no playback control methods - The CDP
Animationdomain covers CSS/Web Animations only —<video>/<audio>are architecturally separate - The media pipeline clock runs on wall-clock time in headless mode (normally driven by the sound card,
which doesn't exist in
chrome-headless-shell). There is no CDP command or Chrome flag to make it respect virtual time video.currentTimeseeking is asynchronous and frame-approximate — rapid sequential seeks trigger known Chromium bugs and are far too slow for frame-by-frame capture
For exact results, decode video server-side (e.g., with ffmpeg), extract frames as images, and display
them as <img> elements on a timed sequence — these render deterministically under puppeteer-capture.
For approximate results, a seek + canvas pattern can be used in page scripts:
// On each frame (inside requestAnimationFrame callback):
video.currentTime = desiredTimeInSeconds
video.addEventListener('seeked', () => {
ctx.drawImage(video, 0, 0)
}, { once: true })Note that this approach is not frame-perfect — seek accuracy depends on codec keyframe placement, audio is not captured, and native video controls are not visible on the canvas.
See #9 for the original report.