← Blog

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.

September 22, 20268 min readRemote Browser

# 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-core metadata 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.waitForSelector resolves but elementHandle.click() throws because the node reference went stale under a changed lifecycle event.
  • Network interception (page.setRequestInterception) stops firing on redirects.
  • Target.attachToTarget semantics 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.1

If 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@stable

Then 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:

  1. Pin the Puppeteer version in `package.json` with an exact version, not a caret range. A minor bump can change the expected Chromium revision.
  2. 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.

ApproachVersion controlDrift riskOps cost
puppeteer bundled downloadTied to package versionMedium — re-resolves on installLow per machine, high across fleet
puppeteer-core + pinned executablePathExplicit, in your imageLow if image is immutableYou own the image, patches, and deps
Container with baked-in ChromiumExplicit, in the DockerfileLowImage size, rebuild cadence, CVEs
Hosted Chromium session (CDP endpoint)Owned by the runtimeLow — one version serves all workersMinimal; 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 session

Two 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 connectOverCDP path.

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-core without 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.

`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:

  1. Pin puppeteer-core to an exact version in package.json.
  2. Log browser.version() at the start of every session.
  3. Stop shipping Chromium in your images — connect to a hosted session over CDP instead.
  4. 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.