← Blog

BLOG

Puppeteer Remote Browser Download: What You Install vs Connect

Puppeteer remote browser download explained: what `puppeteer browsers install chrome` actually fetches, and when to connect to hosted Chromium instead.

September 18, 202610 min readRemote Browser

# Puppeteer Remote Browser Download: What You Install vs Connect

If you searched for a "puppeteer remote browser download," you probably want one of two things: the Chrome binary that Puppeteer needs locally, or a way to run Puppeteer against a browser that isn't on your machine. Those are different problems, and the commands people copy from each other often solve the wrong one. This guide separates them: what puppeteer browsers install chrome downloads, what browserWSEndpoint actually points at, and how to connect Puppeteer to a hosted Chromium session when local downloads stop being practical.

The two meanings of "Puppeteer remote browser download"

Puppeteer has always shipped with a browser download step. Historically, npm install puppeteer pulled a pinned Chrome build into a cache directory. Since Puppeteer v19+, that behavior is more explicit: you can install Puppeteer as a library and manage browser binaries separately with the @puppeteer/browsers CLI.

That gives you two distinct workflows:

  1. Local binary management. You download Chrome/Chromium to disk and launch it as a child process. Puppeteer talks to it over a local pipe or WebSocket.
  2. Remote connection. You skip the download entirely and connect to a browser already running somewhere else — a container, a VM, or a hosted runtime — using puppeteer.connect() with a WebSocket endpoint.

The phrase "remote browser download" blurs these. A remote browser isn't something you download; it's something you connect to. What you download is the *client* — the Puppeteer library and, optionally, a local Chrome for development.

What puppeteer browsers install chrome actually does

The modern CLI is @puppeteer/browsers. It's a standalone package that manages browser binaries independently of the Puppeteer library version.

# Install the CLI
npm install @puppeteer/browsers

# Download a specific Chrome build into a cache dir
npx @puppeteer/browsers install chrome@stable

# Or pin an exact version
npx @puppeteer/browsers install chrome@121.0.6167.85

# List what's already cached
npx @puppeteer/browsers list

Key behaviors worth knowing:

  • Binaries land in a cache directory, not node_modules. On Linux that's typically ~/.cache/puppeteer; on macOS it's under ~/Library/Caches/puppeteer. This matters for Docker images and CI runners, where the cache path is often ephemeral.
  • `chrome` and `chromium` are different channels. chrome is the branded Google build; chromium is the open-source build. They have different version cadences and slightly different feature sets.
  • `chrome-headless-shell` is a separate download. If you only need headless automation, this is a smaller binary than full Chrome. Puppeteer can use it, but some features (extensions, certain rendering paths) differ.
  • Version pinning is explicit. chrome@stable tracks the stable channel; an exact version string locks you to a build. Reproducible CI usually wants the exact version.

If you're using the full puppeteer package rather than puppeteer-core, the install script handles this for you. puppeteer-core never downloads a browser — it expects you to supply one, either locally or via a connection URL.

Why the download step causes problems in production

The download is fine on a laptop. In production it creates friction:

  • Image size. A full Chrome build adds a large amount of disk to a container image.
  • Cold-start latency. Downloading on first boot adds seconds to every fresh instance.
  • Version drift. Different services pin different Chrome versions, and a shared cache can serve the wrong one.
  • Missing system libraries. Chrome needs a set of shared libraries (libnss3, libatk, libgbm, and friends). Minimal base images don't have them, and the failure mode is a cryptic launch error.
  • Sandbox requirements. Chrome's sandbox needs specific kernel capabilities. Many container runtimes require --no-sandbox, which weakens isolation.

None of these are fatal, but together they're why teams eventually ask whether they need to download a browser at all.

Connecting instead of downloading: browserWSEndpoint

Puppeteer's remote story is puppeteer.connect(). You give it a WebSocket URL, and it attaches to an already-running browser over the Chrome DevTools Protocol (CDP).

import puppeteer from 'puppeteer-core';

const browser = await puppeteer.connect({
  browserWSEndpoint: 'wss://your-endpoint.example/devtools/browser/<id>',
  defaultViewport: null,
});

const page = await browser.newPage();
await page.goto('https://example.com', { waitUntil: 'networkidle2' });
const title = await page.title();
console.log(title);

// Disconnect without killing the remote browser
await browser.disconnect();

Three details that trip people up:

  • `disconnect()` vs `close()`. disconnect() detaches your client and leaves the browser running. close() sends a command that shuts the browser down. For a hosted session you usually want disconnect().
  • `puppeteer-core` is the right package here. The full puppeteer package bundles a download step you don't need when connecting remotely.
  • The endpoint is a capability. Anyone with the WebSocket URL can drive that browser. Treat it like a credential — don't log it, don't commit it, and rotate it per session where the runtime supports it.

The browserWSEndpoint you get from a hosted runtime is typically per-session and short-lived. That's a feature: it means a leaked URL has a bounded blast radius.

Puppeteer vs Playwright for remote connections

Both libraries speak CDP, but their remote ergonomics differ. If you're choosing a client for a hosted runtime, this table is the practical summary.

ConcernPuppeteerPlaywright
Remote connect APIpuppeteer.connect({ browserWSEndpoint })chromium.connectOverCDP(endpoint)
Package for remote-only usepuppeteer-coreplaywright-core
Browser download on installYes, with full puppeteerYes, with full playwright
Multi-browser supportChrome/Chromium/Firefox (limited)Chromium, Firefox, WebKit
Auto-waitingManual (waitForSelector, etc.)Built into locators
CDP session accesspage.target().createCDPSession()context.newCDPSession(page)
Best fitChrome-centric automation, existing Puppeteer codeCross-browser testing, newer agent code

Neither is wrong. If you have a working Puppeteer codebase, puppeteer.connect() is a two-line change. If you're starting fresh and want cross-browser coverage, Playwright's connectOverCDP is the equivalent path — see the Playwright CDP documentation for the exact semantics, including the Chromium-only constraint.

A TypeScript example: Playwright over CDP to a hosted session

Most teams that outgrow local downloads end up on Playwright for new code, because the auto-waiting behavior reduces flaky selectors. Here's a typed example that connects to a hosted Chromium session over CDP, creates a context, and cleans up properly.

import { chromium, type Browser, type BrowserContext } from 'playwright-core';

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

async function runTask(session: SessionInfo): Promise<string> {
  let browser: Browser | undefined;
  let context: BrowserContext | undefined;

  try {
    // connectOverCDP attaches to an existing browser; it does not launch one.
    browser = await chromium.connectOverCDP(session.cdpUrl, {
      timeout: 30_000,
    });

    // A hosted session usually exposes one default context.
    context = browser.contexts()[0] ?? (await browser.newContext());

    const page = await context.newPage();
    await page.goto('https://example.com', {
      waitUntil: 'domcontentloaded',
      timeout: 45_000,
    });

    // Locators auto-wait; no manual sleep needed.
    const heading = await page.locator('h1').first().innerText();
    return heading;
  } finally {
    // Close the page/context you created, then detach.
    await context?.close().catch(() => {});
    await browser?.close().catch(() => {});
  }
}

const session: SessionInfo = {
  cdpUrl: process.env.REMOTE_BROWSER_CDP_URL!,
  sessionId: process.env.REMOTE_BROWSER_SESSION_ID!,
};

runTask(session)
  .then((h) => console.log('heading:', h))
  .catch((err) => {
    console.error('task failed:', err);
    process.exitCode = 1;
  });

Notes on the code:

  • `connectOverCDP` is Chromium-only. It won't attach to Firefox or WebKit. If you need those, you need a different transport.
  • `browser.close()` on a CDP connection closes the connection and, depending on the runtime, may terminate the remote browser. If you want to preserve session state, detach instead and let the runtime reap the session on its own schedule.
  • Timeouts are explicit. Remote connections add network latency that local pipes don't have. Default timeouts that work locally often need to be raised.
  • `playwright-core` avoids the browser download. Same reasoning as puppeteer-core.

Browser args and version pinning on remote sessions

puppeteer browser args and puppeteer browser version are common follow-up searches, and they behave differently when the browser isn't yours.

Locally, you pass args at launch:

const browser = await puppeteer.launch({
  args: ['--no-sandbox', '--disable-dev-shm-usage', '--window-size=1280,800'],
});

Remotely, you can't pass launch args — the browser is already running. Instead, the runtime exposes configurable browser settings: viewport, locale, timezone, user agent, proxy configuration, and similar session-level options. If you need a specific Chrome version, you select it when creating the session rather than pinning it in your client code.

This is a real trade-off. You lose fine-grained control over the process, and you gain consistency: every session starts from the same known configuration, and you're not debugging why one container has a different libgbm version than another.

For a deeper look at how session configuration and profiles work on a hosted runtime, see Remote Browser for AI agents.

When to download, when to connect

A decision table, since this is the actual question behind the search.

SituationDownload locallyConnect to remote
Local development and debuggingOptional
One-off scripts on your laptopOverkill
CI with a stable base image✅ (cached)
Serverless / short-lived functions
Horizontal scale beyond a few instances
Sessions that must survive worker restarts
Need for persistent profiles across runs
Strict reproducibility across environments
Air-gapped or offline environments

The pattern: local downloads are fine when the environment is stable and the browser's lifetime matches the process's lifetime. Remote connections win when the browser needs to outlive the worker, or when you're running enough instances that per-instance browser management becomes a maintenance burden.

Production criteria for a remote browser runtime

If you're evaluating hosted Chromium for Puppeteer or Playwright, these are the things that actually matter in production — not the marketing bullets.

  • CDP compatibility. Does it expose a standard browserWSEndpoint that puppeteer.connect() and chromium.connectOverCDP() accept without patched clients? If you need a custom SDK, that's a lock-in signal.
  • Session isolation. Are sessions isolated from each other at the process or container level? Shared browser processes leak state between tenants.
  • Persistent profiles. Can a session retain cookies and local storage across runs? This is essential for authenticated workflows and impossible with ephemeral local Chrome.
  • Live viewer. Can you watch a session in real time when something fails? Debugging a headless remote browser without a viewer is painful.
  • Proxy and network controls. Per-session proxy configuration, and clarity about which egress IPs you'll see.
  • Usage controls. Per-session time limits, concurrency caps, and a way to see what you're spending. Check /pricing for current rates rather than trusting a blog post.
  • Observability. Session logs, CDP event access, and the ability to attach a debugger mid-session.

For a broader comparison of hosted versus self-managed browser infrastructure, Remote Browser online covers the operational trade-offs.

Migrating an existing Puppeteer script

The migration is smaller than it looks. The typical diff:

- const browser = await puppeteer.launch({
-   headless: 'new',
-   args: ['--no-sandbox'],
- });
+ const browser = await puppeteer.connect({
+   browserWSEndpoint: process.env.REMOTE_BROWSER_WS_ENDPOINT,
+   defaultViewport: null,
+ });

Then:

  1. Swap `puppeteer` for `puppeteer-core` in package.json to drop the download step.
  2. Remove `--no-sandbox` and other launch args. They're the runtime's concern now.
  3. Move viewport and user-agent settings from launch options to session creation or page.setViewport().
  4. Replace `browser.close()` with `browser.disconnect()` if you want the session to persist.
  5. Raise timeouts. Network hops add latency; 30s defaults may be too tight.
  6. Handle reconnection. Remote sessions can drop. Wrap your task in retry logic that creates a fresh session rather than assuming the old endpoint is still valid.

Step 6 is the one people skip, and it's the one that causes production incidents.

Where Remote Browser fits

Remote Browser provides hosted Chromium sessions with CDP access, so puppeteer.connect() and chromium.connectOverCDP() work without a custom client. Sessions support persistent profiles, configurable browser settings including proxy configuration, a live viewer for debugging, and per-session usage controls.

The practical upshot for the "download" question: you install puppeteer-core or playwright-core, you never download a browser binary, and you connect to a session that's already running. That removes the image-size, cold-start, and version-drift problems described earlier — at the cost of a network hop and a dependency on the runtime's availability.

Start with the documentation for connection details and session lifecycle, and check /pricing for current usage rates. If you're still deciding between local and remote, Remote web browser walks through the runtime model in more depth.

Summary

  • puppeteer browsers install chrome downloads a Chrome binary to a local cache. It does not give you a remote browser.
  • A remote browser is something you *connect* to via puppeteer.connect({ browserWSEndpoint }) or chromium.connectOverCDP().
  • Use puppeteer-core / playwright-core when connecting remotely — they skip the download entirely.
  • Launch args don't apply to remote sessions; session-level configuration replaces them.
  • Download locally for development and stable CI; connect remotely for serverless, horizontal scale, persistent profiles, and sessions that outlive workers.
  • The migration is a handful of lines plus retry logic. The retry logic is the part that matters.