Puppeteer Install Browser: Local vs Remote CDP Setup
When you run npm install puppeteer, the package downloads a pinned Chrome build into a local cache directory. That is the default "puppeteer install browser" step, and it works fine on a laptop. It becomes a problem the moment you deploy to a container, a CI runner, or a fleet of agent workers: the download is large, the cache path differs per OS, and the browser binary has to match the Puppeteer version that launched it. This guide covers what the install actually does, how to control it, and when to stop installing Chrome locally and connect Puppeteer to a hosted Chromium session over CDP instead.
What puppeteer install browser actually does
Puppeteer ships two packages. puppeteer runs a postinstall script that fetches a browser; puppeteer-core does not. That single distinction drives most install decisions.
When you install the full puppeteer package, the install script:
- Reads the browser version pinned to that Puppeteer release.
- Downloads the matching Chrome for Testing build (or Chrome Headless Shell, depending on config).
- Extracts it into a cache directory —
~/.cache/puppeteeron Linux,~/Library/Caches/puppeteeron macOS,%USERPROFILE%\.cache\puppeteeron Windows. - Records the revision so
puppeteer.launch()can find the binary without you passing an executable path.
The cache is keyed by browser and revision, so upgrading Puppeteer can trigger a second download rather than reusing the first. On a build machine with no persistent cache, every clean install pays that cost again.
Environment variables that control the install
You can steer the install without touching code:
-
PUPPETEER_SKIP_DOWNLOAD=true— skip the browser download entirely. Use this when you only needpuppeteer-corebehavior or you plan to connect to a remote browser. -
PUPPETEER_CACHE_DIR=/path— relocate the cache, useful for Docker layers and CI caching. -
PUPPETEER_DOWNLOAD_BASE_URL— point at an internal mirror if your network blocks the default CDN. -
PUPPETEER_EXECUTABLE_PATH— at runtime, force Puppeteer to use a specific Chrome binary instead of the cached one.
If you install puppeteer-core, none of this applies. You get the API surface with no bundled browser, and you must supply a browser yourself — either a system Chrome via executablePath, or a remote endpoint via browserWSEndpoint or browserURL.
Local install vs remote connect
The install question is really a deployment question. Here is the trade-off in concrete terms.
| Dimension | Local puppeteer install |
puppeteer-core + remote CDP |
|---|---|---|
| Browser binary | Downloaded per install, cached on disk | None; browser runs elsewhere |
| Image size | +300–500 MB typical | Package only, tens of MB |
| Version coupling | Puppeteer pins the Chrome revision | You control the remote browser version |
| Cold start | Download + extract on first run | Connect handshake, typically sub-second |
| Scaling | One browser per process/container | Many sessions against hosted Chromium |
| Session persistence | Tied to the process lifetime | Profiles can outlive the worker |
| Debugging | Local DevTools | Live viewer or CDP over the wire |
| Best fit | Local dev, one-off scripts | CI, serverless, agent fleets |
The pattern that holds up in production: use the full puppeteer package locally so you get a working browser with zero configuration, and use puppeteer-core in deployed code where the browser is a remote resource. That keeps your dependency tree honest — your production code never silently depends on a downloaded binary that may not exist in the runtime.
Connecting Puppeteer to a remote browser
Puppeteer connects to a remote Chromium in two ways. puppeteer.connect({ browserWSEndpoint }) uses the WebSocket endpoint that Chrome exposes when started with --remote-debugging-port. puppeteer.connect({ browserURL }) hits the HTTP endpoint and resolves the WebSocket URL for you. Both speak the Chrome DevTools Protocol, so anything you can do with a local browser you can do against a hosted one.
The endpoint is the only thing that changes. Your page logic — selectors, waits, evaluation — stays identical.
import puppeteer, { Browser, Page } from "puppeteer-core";
// The WebSocket endpoint comes from your browser provider.
// It looks like: wss://<host>/cdp/<session-id>
const browserWSEndpoint = process.env.BROWSER_WS_ENDPOINT!;
async function runTask(): Promise<void> {
let browser: Browser | undefined;
try {
browser = await puppeteer.connect({
browserWSEndpoint,
defaultViewport: { width: 1280, height: 800 },
// Keep the session alive if the socket blips.
protocolTimeout: 120_000,
});
const page: Page = await browser.newPage();
// Route through the remote browser's network stack.
await page.goto("https://example.com", {
waitUntil: "networkidle2",
timeout: 45_000,
});
const title = await page.title();
const links = await page.$$eval("a", (anchors) =>
anchors.map((a) => (a as HTMLAnchorElement).href).slice(0, 10)
);
console.log({ title, links });
} catch (err) {
// Surface the CDP error rather than swallowing it.
console.error("Remote browser task failed:", err);
throw err;
} finally {
// Disconnect, do not close — the remote session may be reused.
if (browser) await browser.disconnect();
}
}
runTask();
Two details matter here. First, browser.disconnect() instead of browser.close(). Closing a remote browser tears down a session you may not own; disconnecting releases your client while leaving the session intact for reuse or inspection. Second, protocolTimeout guards against a stalled CDP call hanging your worker indefinitely — worth setting explicitly rather than relying on defaults.
If you are wiring this into a Playwright-based stack instead, the same endpoint works through chromium.connectOverCDP(). The Playwright CDP guide documents the connection semantics, and the protocol underneath is identical.
Production criteria for the remote path
Connecting is easy. Running it reliably is the part that needs decisions.
Session lifecycle. Decide up front whether a session is per-task or long-lived. Per-task sessions are simpler to reason about and isolate failures. Long-lived sessions preserve login state and cookies but need explicit cleanup. Remote Browser exposes persistent profiles for the second case, so a worker can reconnect to a session that already holds an authenticated state.
Endpoint stability. A WebSocket endpoint that changes on every reconnect forces you to re-fetch it from an API before each task. Prefer a provider that gives you a stable connection URL per session, or an API call that returns the current endpoint for a session ID.
Isolation. If two agents share a browser, they share cookies, storage, and possibly a page context. Session isolation at the browser level is the safe default; sharing a browser across tenants is not.
Observability. When a remote task fails, you need to see what the browser saw. A live viewer that streams the session is far more useful than a stack trace alone. Pair it with CDP-level logging so you can correlate agent actions with page state.
Cost model. Remote browsers are usually metered by session time. Understand whether you pay for idle sessions, how disconnects are billed, and whether there is a minimum increment. Current rates are on the /pricing page rather than baked into this post, since they change.
Network posture. If your targets are geo-restricted or rate-limited, the browser's egress IP matters as much as the browser itself. Configurable proxy settings at the session level let you route traffic without changing your Puppeteer code.
Common failure modes
Most "remote browser not working" reports trace back to a handful of causes.
-
Wrong endpoint scheme.
browserWSEndpointneedsws://orwss://. Passing anhttp://URL to it fails immediately. UsebrowserURLfor HTTP endpoints. - Session already closed. Reconnecting to an expired session returns a connection error. Fetch a fresh endpoint rather than retrying a dead one.
-
Version skew. A very old
puppeteer-coreagainst a very new Chromium can hit unsupported CDP methods. Keep the client reasonably current. -
Missing
--remote-debugging-address. If you self-host Chrome and it only binds to localhost, remote clients cannot reach it. Hosted providers handle this for you. -
Timeouts on heavy pages. Default navigation timeouts are short. Set
waitUntilandtimeoutexplicitly for pages with client-side rendering.
For a broader look at how hosted sessions fit into agent architectures, see Remote Browser for AI Agents.
When to keep the local install
The remote path is not universally better. Keep the local puppeteer install when:
- You are developing and iterating on selectors, where a local browser and DevTools are faster to inspect.
- You need to test against a specific Chrome build that you control end to end.
- Your workload is a single script on a machine that already has Chrome.
- You are debugging a CDP interaction and want zero network variables.
Switch to puppeteer-core plus a remote endpoint when you deploy, when you need more concurrent sessions than one machine can hold, when you want sessions to survive worker restarts, or when you need a live view of what an agent is doing. The remote browser overview covers the runtime model in more depth, and the /documentation has the connection specifics for each supported client.
A practical migration path
If you have a working local Puppeteer script and want to move it to a hosted browser without a rewrite:
- Swap the dependency from
puppeteertopuppeteer-core. - Replace
puppeteer.launch({ ... })withpuppeteer.connect({ browserWSEndpoint }). - Move any launch flags you relied on — viewport, user agent, proxy — into the session configuration on the provider side.
- Replace
browser.close()withbrowser.disconnect()unless you genuinely want to end the session. - Add a retry wrapper that fetches a fresh endpoint on connection failure.
That is the whole change. The page automation code — the part you actually spent time on — does not move.
The install step is a deployment detail, not an architectural one. Treat it that way: install locally for development, connect remotely for production, and keep the browser binary out of your deployed artifact.
Top comments (0)