DEV Community

RemoteBrowser
RemoteBrowser

Posted on Originally published at remote-browser.dev

Puppeteer Browsers Npm: Install, Connect, and Run Remotely

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
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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:

  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.

Top comments (0)