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.
# 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 chromeEach 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 onnpm install(unlessPUPPETEER_SKIP_DOWNLOADis 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:
- Cold-start cost. A fresh
npm ciin 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. - Version drift. If you do not pin the build ID,
chrome@stableresolves to whatever is current that day. A Chrome release can change rendering, network behavior, or CDP surface between your test runs. - 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. - Architecture mismatch.
chrome-headless-shelland full Chrome have different build matrices. Cross-compiling or running on ARM runners means verifying the right artifact exists. - Sandbox and privilege issues. Running Chrome as root in a container requires
--no-sandboxor 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
| Dimension | puppeteer browsers local install | Hosted Chromium via CDP |
|---|---|---|
| Setup step | Download + unpack per machine | Paste a WebSocket URL |
| Image size | +150–200 MB per browser | No browser in your artifact |
| Version control | You pin build IDs manually | Runtime owns the build |
| System deps | You install libnss3, libgbm, etc. | Handled by the runtime |
| Cold start | Seconds to minutes on first run | Connection handshake |
| Session persistence | You manage profile dirs | Persistent profiles available |
| Debugging | Local DevTools or screenshots | Live viewer + CDP |
| Scaling model | One browser per container | Sessions provisioned per request |
| Best fit | Local dev, offline work | CI, 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:
- Swap
puppeteerforpuppeteer-coreinpackage.json. - Replace
puppeteer.launch({ ... })withpuppeteer.connect({ browserWSEndpoint }). - Remove
PUPPETEER_CACHE_DIR, browser install steps, and the associated Docker layers. - Move launch options that no longer apply (like
executablePath) into runtime configuration. - 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.