← Blog

BLOG

Puppeteer Browsers Npm: Install, Connect, and Run Remotely

Understand puppeteer browsers npm installs, version pinning, and how to connect Puppeteer to a hosted Chromium runtime instead of downloading Chrome locally.

September 19, 20268 min readRemote Browser

# Puppeteer Browsers Npm: Install, Connect, and Run Remotely

If you have run npm install puppeteer recently, you already know the surprise: the package pulls a Chromium build into your node_modules or cache directory, and that download is often the slowest, most fragile part of your CI pipeline. The puppeteer browsers CLI exists to manage exactly that — installing, listing, and pinning browser binaries. This guide covers what the puppeteer browsers npm workflow actually does, where it breaks in production, and how to connect Puppeteer to a hosted Chromium runtime so you stop shipping browser binaries with your app.

What puppeteer browsers Actually Manages

Puppeteer ships two related things: the Node library and a browser binary. Since Puppeteer v19+, browser management moved into a dedicated CLI and a set of npm packages under the @puppeteer/browsers namespace. The command surface looks like this:

npx puppeteer browsers install chrome
npx puppeteer browsers install chrome@stable
npx puppeteer browsers install chrome-headless-shell@121.0.6167.85
npx puppeteer browsers list
npx puppeteer browsers uninstall chrome

Each command resolves a browser "build ID" (a version string like 121.0.6167.85 or a channel like stable, beta, canary, dev), downloads the matching archive from Google's Chrome for Testing endpoints, and unpacks it into a cache directory. On Linux that is typically ~/.cache/puppeteer; on macOS it is ~/Library/Caches/puppeteer. The path is configurable via PUPPETEER_CACHE_DIR.

The key detail for production: the browser is not part of your application artifact. It is a side-loaded binary that must exist on the machine that runs puppeteer.launch(). That distinction drives most of the operational pain.

Version pinning and the puppeteer vs puppeteer-core split

  • puppeteer — installs a browser on npm install (unless PUPPETEER_SKIP_DOWNLOAD is set) and exposes the full API.
  • puppeteer-core — no browser download, no install hooks. You supply an executable path or a CDP endpoint.

For containerized workloads, puppeteer-core is almost always the right dependency. It keeps your image small and removes a network-dependent step from npm ci. You then either bake a browser into the image or — more usefully — connect to a browser that already exists somewhere else.

Where the Local Install Model Breaks Down

The puppeteer browsers npm flow is fine for a laptop. It gets awkward at scale for predictable reasons:

  1. Cold-start cost. A fresh npm ci in CI pulls a ~150–200 MB archive per job. Multiply by parallel runners and you are paying for bandwidth and wall-clock time on every build.
  2. Version drift. If you do not pin the build ID, chrome@stable resolves to whatever is current that day. A Chrome release can change rendering, network behavior, or CDP surface between your test runs.
  3. Missing system libraries. Chrome needs a long list of shared libraries (libnss3, libatk, libgbm, and friends). Minimal base images do not have them, and the failure mode is a cryptic launch error rather than a clear dependency message.
  4. Architecture mismatch. chrome-headless-shell and full Chrome have different build matrices. Cross-compiling or running on ARM runners means verifying the right artifact exists.
  5. Sandbox and privilege issues. Running Chrome as root in a container requires --no-sandbox or a properly configured user namespace. Both have security implications you should decide on deliberately, not by copy-pasting a flag.

None of these are fatal. They are the tax you pay for owning the browser lifecycle. The question is whether that ownership buys you anything.

The Alternative: Connect Instead of Install

Puppeteer's connect() method attaches to an existing browser over the Chrome DevTools Protocol. If a browser is already running somewhere reachable, you skip installation entirely:

import puppeteer from 'puppeteer-core';

// A hosted runtime exposes a CDP WebSocket endpoint.
// Treat this URL as a secret — it grants full control of the session.
const browser = await puppeteer.connect({
  browserWSEndpoint: process.env.BROWSER_WS_ENDPOINT!,
  defaultViewport: { width: 1280, height: 800 },
});

const page = await browser.newPage();
await page.goto('https://example.com', { waitUntil: 'networkidle2' });

const title = await page.title();
const links = await page.$$eval('a', (nodes) =>
  nodes.slice(0, 10).map((n) => (n as HTMLAnchorElement).href),
);

console.log({ title, links });

// Close the page, but do not kill the remote browser unless you own it.
await page.close();
browser.disconnect();

Two things matter here. First, browser.disconnect() detaches your client without terminating the remote process — the opposite of browser.close(). Second, the browserWSEndpoint is a credential. Anyone with that URL can drive the session, read cookies, and navigate. Store it in a secret manager, not in a checked-in .env.

The same pattern works with Playwright's connectOverCDP, which is documented in the Playwright CDP guide. If you are evaluating both libraries, the connection model is nearly identical; the difference is in the higher-level API.

Local Install vs Hosted Runtime: A Comparison

Dimensionpuppeteer browsers local installHosted Chromium via CDP
Setup stepDownload + unpack per machinePaste a WebSocket URL
Image size+150–200 MB per browserNo browser in your artifact
Version controlYou pin build IDs manuallyRuntime owns the build
System depsYou install libnss3, libgbm, etc.Handled by the runtime
Cold startSeconds to minutes on first runConnection handshake
Session persistenceYou manage profile dirsPersistent profiles available
DebuggingLocal DevTools or screenshotsLive viewer + CDP
Scaling modelOne browser per containerSessions provisioned per request
Best fitLocal dev, offline workCI, agents, multi-tenant automation

The honest read: local installs win when you need a specific patched build, work offline, or want zero external dependencies. Hosted runtimes win when browser lifecycle is not your product.

Production Criteria Before You Switch

Before moving Puppeteer workloads to a remote runtime, check these against your requirements:

  • CDP compatibility. Confirm the runtime exposes a standard CDP endpoint. Puppeteer's connect() expects a WebSocket URL; some providers only offer REST wrappers.
  • Session isolation. Each task should get its own browser context or process. Shared state across tenants is a correctness and security problem.
  • Profile persistence. If your workflow logs in once and reuses cookies, you need persistent profiles with a defined retention policy.
  • Proxy and network controls. Egress IP, geolocation, and header behavior should be configurable per session, not global.
  • Observability. A live viewer or session recording turns "the agent failed" into "the agent clicked the wrong element at step 6."
  • Usage accounting. Browser time is the unit that scales. Know how sessions are metered before you commit to a volume.

Remote Browser exposes hosted Chromium sessions with CDP access, Playwright/Puppeteer/Selenium compatibility, persistent profiles, configurable browser settings, session isolation, and a live viewer. Current limits and metering are on the /pricing page — check there rather than assuming a number.

Wiring It Into an Existing Puppeteer Codebase

The migration is usually smaller than teams expect. The main changes:

  1. Swap puppeteer for puppeteer-core in package.json.
  2. Replace puppeteer.launch({ ... }) with puppeteer.connect({ browserWSEndpoint }).
  3. Remove PUPPETEER_CACHE_DIR, browser install steps, and the associated Docker layers.
  4. Move launch options that no longer apply (like executablePath) into runtime configuration.
  5. Keep --no-sandbox-style flags out of your code; the runtime owns process configuration.

If you use puppeteer.launch() with a browserURL instead of a WebSocket, note that browserURL points at the HTTP debugging endpoint (usually port 9222), while browserWSEndpoint is the WebSocket. Mixing them up produces a connection error that looks like a network problem but is actually a protocol mismatch.

For a broader look at how hosted sessions fit into agent workflows, see /blog/remote-browser-for-ai-agents. If you are comparing connection styles across libraries, /blog/remote-control-browser covers the control-plane side.

Common Failure Modes and How to Read Them

`Could not find browser revision` — you are on puppeteer (not puppeteer-core) and the install step was skipped or the cache was cleared. Either run npx puppeteer browsers install or switch to puppeteer-core with an explicit endpoint.

`Target closed` immediately after connect — the remote session expired or was reclaimed. Check session TTL and whether your client is holding the connection open during long idle periods.

`Protocol error (Page.navigate): Session closed` — often a proxy or network policy killing the connection mid-navigation. Verify egress rules and timeouts.

Timeouts on `page.goto` with `networkidle0` — a common Puppeteer footgun. Pages with long-polling or analytics beacons may never reach idle. Prefer domcontentloaded plus an explicit selector wait.

Stale cookies across runs — you are reusing a persistent profile when you meant a fresh context, or vice versa. Decide per workload and make it explicit in code.

When to Keep the Local Install

There are legitimate reasons to stay with puppeteer browsers npm installs:

  • You need a specific Chrome build that a hosted runtime does not offer.
  • Your workload runs in an air-gapped environment.
  • You are testing browser-version-specific behavior and need to switch builds frequently.
  • Your volume is low enough that CI download time is not a real cost.

The decision is not ideological. It is a question of whether browser lifecycle management is core to your product or incidental overhead. For most agent and automation workloads, it is the latter — which is why connecting to a hosted runtime tends to win once you have more than a handful of concurrent sessions.

Getting Started

The fastest path is to keep your existing Puppeteer code and change one function call. Install puppeteer-core, point connect() at a CDP endpoint, and delete the browser download from your build. The /documentation covers session creation, endpoint formats, and profile handling. If you want to see the runtime in action before committing, /blog/remote-browser-online walks through a browser session without any local Chrome install.

The puppeteer browsers npm tooling is genuinely useful — it solved a real problem when Puppeteer stopped bundling browsers into the main package. But it solves a problem you only have if you own the browser. Once you stop owning it, the CLI becomes a local development convenience rather than a production dependency.