BLOG
npx puppeteer browsers install chrome: Local vs Remote
Run npx puppeteer browsers install chrome, then decide whether to keep downloading Chrome locally or connect Puppeteer to a hosted remote browser over CDP.
# npx puppeteer browsers install chrome: Local vs Remote
npx puppeteer browsers install chrome downloads a Chrome for Testing build into a local cache directory so Puppeteer can launch it without a system Chrome install. That single command solves the "which Chrome is Puppeteer using" problem on a developer laptop. It does not solve the same problem on a fleet of CI runners, containers, or agent workers, where every machine repeats the download, pins its own version, and drifts out of sync with the rest.
This guide covers what the command actually does, where it breaks down in production, and how to move the same Puppeteer code onto a hosted Chromium session over CDP when local installs stop being the right answer.
What npx puppeteer browsers install chrome actually does
Puppeteer ships a CLI (@puppeteer/browsers) that manages browser binaries independently of the Node package. The command resolves a Chrome for Testing build, downloads the platform-specific archive, verifies it, and extracts it into a cache path. On the next puppeteer.launch(), Puppeteer looks up that cache instead of searching your system for a Chrome executable.
Useful variants:
npx puppeteer browsers install chrome— installs the default pinned Chrome build for your platform.npx puppeteer browsers install chrome@stable— resolves the current stable channel.npx puppeteer browsers install chrome-headless-shell— installs the lighter headless shell used for headless-only runs.npx puppeteer browsers list— shows what is already cached and where.npx puppeteer browsers install chrome --path ./browsers— writes to a project-local directory instead of the user cache.
The cache location matters. By default it lands under your home directory (~/.cache/puppeteer on Linux, ~/Library/Caches/puppeteer on macOS, %USERPROFILE%\.cache\puppeteer on Windows). In Docker, that path is usually ephemeral unless you mount it, which means every container start re-downloads roughly 150–200 MB before your first test runs.
Why the command exists
Before this CLI, Puppeteer bundled a browser download into npm install, which made installs slow and made version pinning awkward. Splitting browser management into a separate command gives you explicit control: you choose the build, you choose the cache path, and you can pre-bake it into an image. That is a real improvement for local development and for reproducible CI images.
What it does not do
It does not give you a browser that survives a container restart, a shared browser across parallel workers, or a browser with a stable network identity. Each install is a fresh, unauthenticated Chrome with no profile, no cookies, and a datacenter IP. For scraping and agent workloads, those are exactly the properties that cause failures.
Where local Chrome installs break in production
The install command is fine. The assumptions around it are what cause problems at scale.
Cold-start cost. A 200 MB download per container adds tens of seconds to every scale-up event. On autoscaling infrastructure, that latency shows up directly in task completion time.
Version drift. If one worker installs chrome@stable in January and another in June, they run different builds. Rendering, CDP surface area, and headless behavior change between Chrome majors. Tests that pass on one worker fail on another for reasons unrelated to your code.
Resource contention. Ten parallel Puppeteer workers on one 4-vCPU box means ten Chrome processes competing for memory. Chrome is not lightweight; a single page with a heavy SPA can consume several hundred megabytes.
No persistent identity. Local Chrome starts clean every time. Logging into a site, solving a challenge, or maintaining a session across a multi-step agent task requires profile persistence that a fresh install does not provide.
IP reputation. Requests originate from your CI provider or cloud region. Many sites treat datacenter ranges differently from residential traffic, which affects what your automation can reach.
Debugging blind spots. When a headless run fails in CI, you get logs and maybe a screenshot. You cannot watch the session, inspect the DOM at the moment of failure, or attach a debugger to a live browser.
None of these are reasons to avoid Puppeteer. They are reasons to separate the browser from the machine running your code.
Local install vs hosted remote browser
| Dimension | Local puppeteer browsers install chrome | Hosted remote browser |
|---|---|---|
| Setup | Download per machine or image | Connect over CDP with a session URL |
| Cold start | 150–200 MB download per fresh container | Session provisioned on demand |
| Version control | You pin and rebuild images | Runtime manages the Chromium build |
| Parallelism | Bounded by local CPU/RAM | Scales independently of your worker |
| Session persistence | Manual profile directories | Persistent profiles across sessions |
| Network identity | Your CI/cloud IP | Configurable proxy and browser settings |
| Debugging | Logs, screenshots, local traces | Live viewer plus CDP access |
| Cost model | Compute you already pay for | Metered per browser-hour — see /pricing |
The trade-off is straightforward. Local installs give you zero network dependency and full control over the binary. Hosted sessions give you isolation, persistence, and a browser that is not competing with your application for memory. Most teams start local and move to hosted when parallelism, session lifetime, or site access becomes the bottleneck.
Connecting Puppeteer to a remote browser over CDP
Puppeteer's connect() method attaches to an existing browser instead of launching one. You pass a WebSocket endpoint, and from that point the API is nearly identical to a local launch.
import puppeteer, { Browser, Page } from 'puppeteer-core';
interface SessionInfo {
webSocketDebuggerUrl: string;
}
async function connectToRemoteBrowser(
session: SessionInfo
): Promise<{ browser: Browser; page: Page }> {
const browser = await puppeteer.connect({
browserWSEndpoint: session.webSocketDebuggerUrl,
defaultViewport: { width: 1280, height: 800 },
});
const page = await browser.newPage();
// Route through the session's network configuration.
await page.goto('https://example.com', {
waitUntil: 'networkidle2',
timeout: 45_000,
});
return { browser, page };
}
async function main(): Promise<void> {
const session: SessionInfo = {
webSocketDebuggerUrl: process.env.BROWSER_WS_ENDPOINT!,
};
const { browser, page } = await connectToRemoteBrowser(session);
try {
const title = await page.title();
console.log('Page title:', title);
} finally {
// Disconnect without killing the remote browser.
await browser.disconnect();
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});Two details matter here. First, use puppeteer-core, not puppeteer. The full package tries to manage a local browser download; puppeteer-core assumes you already have an endpoint. Second, call browser.disconnect() rather than browser.close(). Disconnect detaches your client and leaves the remote session running, which is what you want if another worker or a human reviewer needs to inspect it.
The same endpoint works from Playwright via chromium.connectOverCDP(), and from Selenium through a remote WebDriver URL. If you are moving between frameworks, the CDP documentation is the reference for what the protocol exposes.
When to keep the local install
Hosted browsers are not automatically the right choice. Keep npx puppeteer browsers install chrome when:
- You are developing locally and want fast iteration without network round-trips.
- Your test suite runs on a single machine and finishes in a few minutes.
- You need a specific Chrome build that a hosted runtime does not offer.
- Your workload never leaves an internal network and cannot reach an external CDP endpoint.
- You are debugging browser behavior itself and want full control of the binary.
The local install is also the right default for a first prototype. The question is not "local or remote" in the abstract — it is whether your current setup is failing for reasons that a different browser location would fix.
When to move to a hosted runtime
Move when one or more of these is true:
- You run more than a handful of parallel sessions. Local Chrome does not scale linearly with CPU; memory becomes the constraint first.
- Sessions need to outlive a single process. Multi-step agent tasks, human-in-the-loop review, and long-running scrapes all need a browser that persists.
- You need persistent profiles. Logged-in state, cookies, and local storage should survive across runs. See remote browser for AI agents for how profiles and session isolation interact.
- Site access is the blocker. Configurable browser settings and proxy routing change what your automation can reach without changing your code.
- You want to watch sessions live. A viewer turns a failed run from a log line into something you can inspect in real time. Remote control browser covers the operational side of that.
- Your CI bill is dominated by browser downloads. Pre-baking images helps, but a hosted endpoint removes the problem entirely.
If you are evaluating hosted options, the practical comparison points are CDP compatibility, session lifetime limits, profile persistence, proxy configuration, and how the runtime meters usage. Remote browser online walks through what to check before committing.
Migrating without rewriting your automation
The migration path is short because the Puppeteer API does not change. What changes is where the browser lives.
- Swap the dependency. Replace
puppeteerwithpuppeteer-coreinpackage.json. Remove anypostinstallbrowser download step. - Replace `launch()` with `connect()`. Your page-level code — selectors, waits, evaluations — stays as-is.
- Move browser options to the session. Viewport, user agent, and proxy settings become session configuration rather than launch arguments.
- Handle disconnection explicitly. Wrap sessions in
try/finallyand disconnect rather than close. - Add session cleanup. If your runtime bills per browser-hour, an orphaned session is a cost leak. Track session IDs and release them when the task completes or fails.
- Keep a local fallback. A
BROWSER_WS_ENDPOINTenvironment variable that falls back tolaunch()when unset lets developers keep working locally without a second code path.
The last point is worth emphasizing. Teams that hard-code a remote endpoint lose the ability to run tests offline. Teams that abstract the connection behind one function keep both options available.
Practical notes on the install command
A few things that trip people up:
- `npx puppeteer browsers install chrome` requires the `@puppeteer/browsers` package. If
npxcannot resolve it, install it explicitly as a dev dependency. - The cache is not shared across users by default. In CI, set
PUPPETEER_CACHE_DIRto a path you actually cache between runs. - `chrome-headless-shell` is smaller and faster to start but lacks some features of full Chrome. If your automation needs extensions or a full rendering pipeline, use the full build.
- Version pinning is explicit.
chrome@stablemoves;chrome@131.0.6778.85does not. Pin in production. - The install does not configure a profile. If you need persistent state locally, pass
userDataDirtolaunch()and manage that directory yourself.
These are all manageable. The point is that each one is a piece of infrastructure you now own, and that ownership multiplies with the number of machines running your automation.
Choosing based on what actually breaks
The install command answers a narrow question: which Chrome binary should Puppeteer use on this machine? It is the right answer for local development and for small, single-machine test suites.
It stops being the right answer when the browser needs to outlive the process, when parallelism exceeds what one machine can host, when session state needs to persist, or when the network identity of the browser matters more than the code running inside it. At that point the browser becomes infrastructure, and the question shifts from "how do I install Chrome" to "where should Chrome run."
Remote Browser provides hosted Chromium sessions with CDP access, Playwright and Puppeteer compatibility, persistent profiles, a live viewer, and configurable browser settings. You connect with the same puppeteer.connect() call shown above and keep your page-level code unchanged. Current usage details are on /pricing, and the setup path is documented at /documentation.