BLOG
Puppeteer Browser Version: Pin, Match, and Run in Production
Learn how Puppeteer browser version pinning works, why mismatches break automation, and how to run a consistent Chromium version on a remote runtime.
# Puppeteer Browser Version: Pin, Match, and Run in Production
Puppeteer browser version is the single most common source of "it worked yesterday" failures in browser automation. Puppeteer ships a bundled Chromium revision, and that revision is tied to a specific version of the Chrome DevTools Protocol (CDP). When the browser and the Puppeteer client disagree about the protocol, you get silent no-ops, missing events, or hard crashes — usually in the middle of a production run rather than on your laptop.
This guide covers how Puppeteer's version model actually works, how to pin and verify a browser version, and how to move that version off your CI runner and onto a hosted Chromium session so every worker talks to the same browser.
How Puppeteer's Browser Version Model Works
Puppeteer is not a browser. It is a CDP client plus a downloader. Two version numbers matter:
- The Puppeteer package version (e.g.
puppeteer@23.x) — the client library you import. - The Chromium revision that package expects — recorded in the package's
revisions.js/puppeteer-coremetadata and downloaded at install time.
puppeteer (the full package) downloads a matching Chromium build into a local cache during npm install. puppeteer-core does not download anything — it expects you to supply a browser via executablePath or browserWSEndpoint.
That split is the whole story. If you use puppeteer-core and point it at a browser that is older or newer than the client expects, you are running an untested protocol pairing. Most of the time it works. The times it doesn't are expensive.
Why version drift breaks automation
CDP is versioned but not strictly frozen. Chrome ships new protocol domains and changes method signatures on its own release cadence. Practical failure modes when client and browser drift apart:
page.waitForSelectorresolves butelementHandle.click()throws because the node reference went stale under a changed lifecycle event.- Network interception (
page.setRequestInterception) stops firing on redirects. Target.attachToTargetsemantics change and your multi-tab logic silently attaches to the wrong target.- Screenshot and PDF options are accepted but ignored.
None of these throw a clean "version mismatch" error. You get a flaky test suite and a week of bisecting.
Pinning the Browser Version Locally
The default install path is fine for development. For anything reproducible, pin explicitly.
# Full package: downloads a bundled Chromium matching the client version
npm install puppeteer@23.11.1
# Core package: no download, you supply the browser
npm install puppeteer-core@23.11.1If you need a specific Chromium build rather than the bundled default, use the Puppeteer CLI:
npx puppeteer browsers install chrome@131.0.6778.204
npx puppeteer browsers install chrome-headless-shell@stableThen point puppeteer-core at it:
import puppeteer from 'puppeteer-core';
const browser = await puppeteer.launch({
executablePath: process.env.CHROME_PATH, // pinned build
headless: true,
});Two rules that prevent most version incidents:
- Pin the Puppeteer version in `package.json` with an exact version, not a caret range. A minor bump can change the expected Chromium revision.
- Pin the browser build separately and record it in your lockfile or container image. Treat the browser as a dependency, because it is one.
Verifying what you actually got
Never assume the running browser matches the client. Read it back:
const version = await browser.version();
console.log(version); // "HeadlessChrome/131.0.6778.204"Log this at session start. When a run fails, the first question is always "which browser was this?" — and if you didn't log it, you don't know.
The Production Problem: Version Consistency Across Workers
Pinning works beautifully on one machine. It falls apart when you scale.
If you run Puppeteer inside containers, every image build re-resolves the browser download unless you've frozen it. Different CI runners can end up with different Chromium builds. A rolling deploy means two versions are live at once. And if you're running AI agents that drive the browser over many steps, a mid-task version change is not a retry — it's a corrupted session.
The failure is not the browser version itself. It's that the version is a property of your infrastructure, not your code, and infrastructure drifts.
| Approach | Version control | Drift risk | Ops cost |
|---|---|---|---|
puppeteer bundled download | Tied to package version | Medium — re-resolves on install | Low per machine, high across fleet |
puppeteer-core + pinned executablePath | Explicit, in your image | Low if image is immutable | You own the image, patches, and deps |
| Container with baked-in Chromium | Explicit, in the Dockerfile | Low | Image size, rebuild cadence, CVEs |
| Hosted Chromium session (CDP endpoint) | Owned by the runtime | Low — one version serves all workers | Minimal; you connect, you don't build |
The last row is the reason remote runtimes exist. When the browser lives behind a CDP endpoint, every worker — local dev, CI, a fleet of agents — connects to the same browser build. You stop shipping Chromium and start shipping a connection string.
Connecting Puppeteer to a Hosted Chromium Version
Remote Browser exposes hosted Chromium sessions over CDP. You get a WebSocket endpoint and connect with puppeteer.connect instead of puppeteer.launch. The browser version is fixed by the runtime, so your client version is the only variable you manage.
import puppeteer from 'puppeteer-core';
const browser = await puppeteer.connect({
browserWSEndpoint: process.env.REMOTE_BROWSER_WS_ENDPOINT,
defaultViewport: { width: 1280, height: 800 },
});
// Confirm the runtime's Chromium version before doing work
console.log('Connected to:', await browser.version());
const page = await browser.newPage();
await page.goto('https://example.com', { waitUntil: 'domcontentloaded' });
// Persistent profile + session isolation are handled by the runtime,
// so cookies and storage survive across reconnects within the session.
await page.close();
browser.disconnect(); // do NOT call browser.close() on a hosted sessionTwo details that matter in production:
- Use `browser.disconnect()`, not `browser.close()`. Closing a hosted session tears down the browser for everyone attached to it. Disconnect releases your client.
- Match your `puppeteer-core` version to the runtime's Chromium. The runtime publishes the Chromium version; check it against Puppeteer's compatibility table before you upgrade either side. If you're on Playwright instead, the same discipline applies — see the Playwright CDP documentation for the equivalent
connectOverCDPpath.
Playwright/CDP variant
If your stack is Playwright rather than Puppeteer, the connection is nearly identical and the version rule is the same:
import { chromium } from 'playwright';
const browser = await chromium.connectOverCDP(
process.env.REMOTE_BROWSER_WS_ENDPOINT!
);
const context = browser.contexts()[0] ?? await browser.newContext();
const page = await context.newPage();
await page.goto('https://example.com');
console.log('Browser:', browser.version());
await browser.close();Both clients speak CDP. Both are sensitive to the same version skew. The difference is only in the API surface.
Choosing a Browser Version Strategy for Agents
For AI agent workloads — browser-use style loops, multi-step task execution, anything that runs for minutes rather than seconds — version stability matters more than version recency. A few production criteria:
- Freeze the browser version per environment. Dev, staging, and prod should not silently diverge. If you must upgrade, upgrade staging first and run your task suite against it.
- Log the version on every session.
browser.version()at connect time, written to your run metadata. This turns "it broke" into "it broke on 131.0.6778.204." - Prefer a runtime that owns the version. If you're managing Chromium images yourself, you're also managing CVEs, font packages, and headless-shell quirks. A hosted session moves that to the provider.
- Check compatibility before upgrading the client. Puppeteer's release notes state the Chromium revision each version targets. Upgrading
puppeteer-corewithout checking is how you get a protocol mismatch that only shows up under load.
For a broader look at how hosted sessions fit agent architectures, see Remote browsers for AI agents. If you want the runtime model without local Chrome at all, Remote browser online covers the setup path.
Common Version-Related Failures and Fixes
`Protocol error (Target.setAutoAttach): Target closed` — usually a client/browser mismatch or a session that was torn down by another worker. Verify the version, then verify session isolation.
`browser.newPage is not a function` after connect — you connected with a Puppeteer version that expects a different CDP handshake. Check the client version against the runtime's Chromium.
Interception silently stops working — a classic symptom of a newer browser with an older client. The method exists, the behavior changed.
Works locally, fails in CI — your CI image resolved a different Chromium build. Pin it, or connect to a hosted session and remove the variable entirely.
Agent task fails halfway with no error — check whether the browser version changed mid-run. On a hosted runtime with a fixed version, this class of failure disappears.
What to Do Next
The practical sequence:
- Pin
puppeteer-coreto an exact version inpackage.json. - Log
browser.version()at the start of every session. - Stop shipping Chromium in your images — connect to a hosted session over CDP instead.
- Verify the runtime's Chromium version against your client before any upgrade.
Remote Browser provides hosted Chromium sessions with CDP access, Playwright/Puppeteer/Selenium compatibility, persistent profiles, session isolation, and a live viewer for debugging. Version consistency is handled by the runtime, so your workers only need a connection string. See current pricing and session limits for details, and the documentation for the connection flow. If you're driving sessions interactively, remote control browser walks through the live-viewer workflow.