← Blog

BLOG

Puppeteer Browsers Install Chrome: Local vs Remote

Learn how puppeteer browsers install chrome works, where the binaries land, and when to skip local installs for a hosted Chromium runtime.

September 19, 20268 min readRemote Browser

# Puppeteer Browsers Install Chrome: Local vs Remote

puppeteer browsers install chrome is the command Puppeteer gives you to download a specific Chrome build into its local cache instead of relying on whatever browser happens to be on the machine. It exists because modern Puppeteer decoupled browser binaries from the npm package: npm install puppeteer no longer guarantees a working Chrome, and puppeteer browsers install chrome is the explicit fix. This guide covers what that command actually does, where the files go, how to pin versions, and when the right answer is to stop installing Chrome locally and connect to a hosted Chromium session instead.

What puppeteer browsers install chrome actually does

Puppeteer ships a CLI (@puppeteer/browsers under the hood) that resolves a browser name and build ID, downloads the correct archive for your OS and architecture, verifies it, and extracts it into a cache directory. When you run:

npx puppeteer browsers install chrome

Puppeteer resolves the "chrome" channel to a pinned build, downloads it, and prints the install path. That path is what puppeteer.launch() uses when no executablePath is supplied.

Key behaviors worth knowing:

  • It installs into a cache, not `node_modules`. The default location is ~/.cache/puppeteer on Linux/macOS and %USERPROFILE%\.cache\puppeteer on Windows. You can override it with PUPPETEER_CACHE_DIR.
  • It is version-pinned, not "latest Chrome." Puppeteer maps its own release to a compatible Chrome for Testing build. That is deliberate: it keeps the DevTools Protocol surface aligned with the client library.
  • It supports channels. chrome, chrome-headless-shell, chromium, and chrome@stable / chrome@beta / chrome@canary are all valid targets. chrome-headless-shell is the lighter headless-only binary.
  • It is idempotent. Re-running the command against an already-installed build is a no-op.

If you want to see what is installed and where:

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

Where the binaries land and why it matters

The cache layout looks roughly like this:

~/.cache/puppeteer/
  chrome/
    linux-127.0.6533.88/
      chrome-linux64/chrome
  chrome-headless-shell/
    linux-127.0.6533.88/
      chrome-headless-shell-linux64/chrome-headless-shell

Three practical consequences:

  1. Container images get large. A full Chrome build adds a substantial amount of disk. If you build a Docker image per deploy, you are re-downloading or re-baking that binary every time.
  2. Cache directories are per-user, not per-project. Two projects pinning different Chrome builds share one cache root. That is usually fine, but it means disk usage grows silently.
  3. CI runners start cold. A fresh GitHub Actions runner has no cache. Either you cache ~/.cache/puppeteer between runs or you pay the download on every job.

You can point Puppeteer at a specific binary if you already have one:

npx puppeteer browsers install chrome --path /opt/browsers

And in code:

import puppeteer from 'puppeteer';

const browser = await puppeteer.launch({
  executablePath: process.env.CHROME_PATH,
  headless: true,
});

Pinning versions and the puppeteer browsers npm workflow

The puppeteer browsers CLI is part of the @puppeteer/browsers package, which is a dependency of puppeteer and puppeteer-core. You can install it standalone if you want the downloader without the full client:

npm install @puppeteer/browsers
npx @puppeteer/browsers install chrome@127.0.6533.88

This matters for reproducibility. If your test suite passes against Chrome 127 locally and your CI pulls Chrome 130, you will eventually chase a protocol or rendering difference that has nothing to do with your code. Pin the build ID in a script or a .puppeteerrc.cjs:

// .puppeteerrc.cjs
module.exports = {
  cacheDirectory: '/opt/puppeteer-cache',
};

A common pattern is a postinstall script that installs the exact build your package.json expects, so a fresh clone is runnable without a manual step.

When local installs stop scaling

Local Chrome installs are fine for a laptop and a single test suite. They get awkward when:

  • You run many concurrent sessions. Each Chrome process is memory-hungry. Ten parallel sessions on one box is a capacity planning problem, not a config problem.
  • You need persistent profiles. Cookies, localStorage, and logged-in state have to survive across runs, which means managing profile directories and locking.
  • You need a different IP. Local Chrome uses your machine's network. Residential or regional proxies require either a proxy config or a runtime that handles it.
  • You want to debug a live session. A headless process on a CI runner is not something you can watch.
  • You want the same environment across languages. If part of your stack is Python and part is Node, you now maintain two browser install paths.

This is the point where teams move the browser out of the application process and into a runtime they connect to. Remote Browser provides hosted Chromium sessions with CDP access, so Puppeteer, Playwright, and Selenium clients connect the same way they would to a local Chrome — just over a WebSocket endpoint instead of a spawned process. The remote browser for AI agents post covers the runtime model in more depth.

Local install vs hosted Chromium: a comparison

DimensionLocal puppeteer browsers install chromeHosted Chromium session
SetupDownload binary per machine/CI runnerConnect to a WebSocket endpoint
Version controlPin build ID, manage cache dirRuntime-managed; request a version
ConcurrencyBounded by host RAM/CPUScales independently of your app process
Persistent profilesManual profile dirs and lockingManaged profiles per session
Proxy / networkConfigure per launchConfigurable at the session level
Live debuggingAttach a local viewer or DevToolsBuilt-in live viewer
Cross-languageOne install path per runtimeSame endpoint for Node, Python, etc.
Cost modelCompute you already pay forMetered per browser-hour — see /pricing
Best forLocal dev, small test suitesProduction agents, parallel workloads

Neither column is universally better. The trade-off is control versus operational overhead. If you need a specific patched Chrome build for a compliance reason, local installs give you that. If you need fifty concurrent sessions with isolated profiles and a live view, a hosted runtime is the shorter path.

Connecting Puppeteer to a remote Chromium session

The connection pattern is the same one Puppeteer uses for any remote Chrome: puppeteer.connect() with a browserWSEndpoint. You get that endpoint from your runtime.

import puppeteer, { Browser, Page } from 'puppeteer-core';

interface SessionInfo {
  browserWSEndpoint: string;
  sessionId: string;
}

async function runTask(session: SessionInfo): Promise<void> {
  const browser: Browser = await puppeteer.connect({
    browserWSEndpoint: session.browserWSEndpoint,
    defaultViewport: { width: 1280, height: 800 },
  });

  try {
    const page: Page = await browser.newPage();

    // Route through the session's network settings if configured.
    await page.goto('https://example.com', {
      waitUntil: 'networkidle2',
      timeout: 30_000,
    });

    const title = await page.title();
    console.log(`session=${session.sessionId} title=${title}`);

    // CDP is available directly when you need lower-level control.
    const client = await page.createCDPSession();
    await client.send('Network.enable');
  } finally {
    // Disconnect, don't close — the runtime owns the browser lifecycle.
    await browser.disconnect();
  }
}

Two details that trip people up:

  • Use `puppeteer-core`, not `puppeteer`. The full package tries to manage a local binary. puppeteer-core is the client only, which is what you want when connecting to a remote endpoint.
  • Call `disconnect()`, not `close()`. close() terminates the browser. On a hosted runtime you usually want the session to persist or be reclaimed by the platform, not killed mid-flight.

The same endpoint works from Playwright via connectOverCDP, which is useful if you are migrating between libraries. The remote web browser post walks through the Playwright side.

Production criteria before you commit

Before you decide between local installs and a hosted runtime, answer these:

  1. How many concurrent sessions do you need at peak? If it is more than a handful, local Chrome on one host becomes a scheduling problem.
  2. Do sessions need to survive a deploy? Persistent profiles and session isolation are runtime features, not client features.
  3. What is your failure mode? A crashed local Chrome takes down the process that spawned it. A remote session can be reconnected or replaced.
  4. How do you debug a failure? Screenshots and logs are the minimum. A live viewer that lets you watch the session in real time is materially better.
  5. What is your network requirement? If you need regional egress or proxy configuration, that belongs at the session layer.
  6. What does it cost per session-hour? Compare the compute you are already paying for against a metered model. Current rates are on /pricing.

If most of those answers point to "we need isolation, persistence, and observability," the install command is not the bottleneck — the architecture is.

Migrating from local installs without breaking things

A staged migration works better than a rewrite:

  1. Keep `puppeteer browsers install chrome` for local dev. Developers get fast iteration and offline capability.
  2. Introduce an endpoint abstraction. Read the WebSocket endpoint from an environment variable. Locally it is unset and you launch a local browser; in staging and production it points at a hosted session.
  3. Move profile and proxy concerns to the runtime. Stop writing profile directories in your application code.
  4. Add the live viewer to your debugging workflow. Being able to watch a failing session is the fastest way to diagnose selector drift and timing issues.
  5. Retire the CI cache step. Once sessions are remote, your CI job no longer needs to download or cache Chrome.
const endpoint = process.env.BROWSER_WS_ENDPOINT;

const browser = endpoint
  ? await puppeteer.connect({ browserWSEndpoint: endpoint })
  : await puppeteer.launch({ headless: true });

That single branch is the whole migration. Everything else is configuration.

Where to go next

If you are still evaluating, start with the documentation to see the connection model and session controls. If you want to understand how hosted sessions differ from a local Chrome install in practice, read remote browser online. And if you are driving sessions from an agent rather than a test suite, remote control browser covers the control-plane side.

For the underlying protocol details, the Chrome DevTools Protocol documentation is the authoritative reference for the domains Puppeteer and Playwright expose.

puppeteer browsers install chrome is the right tool for local development and small test suites. It is the wrong tool for production workloads that need concurrency, persistence, and observability. Knowing which one you are building is the actual decision.