← Blog

BLOG

Puppeteer In Browser: Run Puppeteer Against Hosted Chromium

Puppeteer in browser workflows need a real Chrome to attach to. Learn how to run Puppeteer against hosted Chromium over CDP, without local installs.

September 16, 20269 min readRemote Browser

# Puppeteer In Browser: Run Puppeteer Against Hosted Chromium

Running Puppeteer in browser environments usually means one of two things: you want Puppeteer to drive a browser that isn't on your machine, or you want to run Puppeteer itself somewhere other than your laptop. Both cases come down to the same mechanism — the Chrome DevTools Protocol (CDP). Puppeteer talks to Chrome over a WebSocket, and that WebSocket doesn't have to point at localhost. Point it at a hosted Chromium session and your script runs unchanged.

This guide covers what "Puppeteer in browser" actually means in practice, how puppeteer.connect() differs from puppeteer.launch(), what breaks when you move to remote Chrome, and how to structure a production setup on a hosted runtime like Remote Browser.

What "Puppeteer in browser" actually means

Puppeteer is a Node library that controls Chrome or Chromium. It has two entry points:

  • puppeteer.launch() — spawns a local Chrome process and connects to it.
  • puppeteer.connect({ browserWSEndpoint }) — attaches to a Chrome instance that is already running, anywhere reachable over the network.

The second form is what makes Puppeteer usable in browser-adjacent contexts: serverless functions, containers without a bundled Chrome, CI runners, and agent runtimes that need a browser they don't own. You're not embedding Puppeteer *inside* a browser tab — you're running Puppeteer as a client and pointing it at a browser that lives elsewhere.

That distinction matters because a lot of confusion comes from conflating three separate problems:

  1. No local Chrome. You don't want to run puppeteer browsers install chrome on every worker.
  2. No persistent state. You need cookies, logins, and localStorage to survive across runs.
  3. No isolation. You need each job to get its own browser context so sessions don't bleed into each other.

A hosted Chromium runtime addresses all three. Puppeteer stays your client library; the browser becomes infrastructure.

connect() vs launch(): the trade-off

If you've only ever used puppeteer.launch(), the mental model shift is small but the operational consequences are large.

Concernpuppeteer.launch() (local)puppeteer.connect() (remote)
Chrome binaryInstalled per machine/containerManaged by the runtime
Startup costProcess spawn + cold start per runAttach to a warm session
Session persistenceManual userDataDir managementProfile handled by the runtime
ScalingBound by host CPU/RAMBound by your session quota
DebuggingLocal DevToolsLive viewer + CDP
Version driftYou pin and patch ChromeRuntime pins the build
Network egressYour IPConfigurable proxy settings

The trade-off is control versus operational burden. Local launch() gives you total control over flags, extensions, and the exact Chrome build — and you pay for it with install scripts, container images, and a Chrome version you have to keep patched. Remote connect() gives up some of that control in exchange for not managing any of it.

For a single developer running one script, local launch is fine. For anything that runs on a schedule, across multiple workers, or inside an agent loop, the install-and-patch cycle becomes the dominant cost.

Connecting Puppeteer to hosted Chromium

The connection flow is the same whether you're pointing at a local Chrome started with --remote-debugging-port or a hosted session. You need a WebSocket debugger URL, and you pass it to connect().

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

// The runtime returns a CDP WebSocket endpoint for the session.
// Treat this like a credential — it grants full control of the browser.
const browserWSEndpoint = process.env.REMOTE_BROWSER_WS_ENDPOINT!;

async function runTask(): Promise<void> {
  let browser: Browser | undefined;

  try {
    browser = await puppeteer.connect({
      browserWSEndpoint,
      // Reuse the session's existing tab instead of opening a new one.
      defaultViewport: null,
    });

    const pages = await browser.pages();
    const page: Page = pages[0] ?? (await browser.newPage());

    await page.goto('https://example.com', {
      waitUntil: 'domcontentloaded',
      timeout: 30_000,
    });

    const title = await page.title();
    console.log('title:', title);

    // Create an isolated context for a second job on the same browser.
    const context = await browser.createBrowserContext();
    const isolated = await context.newPage();
    await isolated.goto('https://example.com/account');
    await context.close();
  } finally {
    // Disconnect, do not close — the runtime owns the browser lifecycle.
    await browser?.disconnect();
  }
}

runTask().catch((err) => {
  console.error(err);
  process.exit(1);
});

Three details in that snippet are worth calling out.

Use `puppeteer-core`, not `puppeteer`. The full puppeteer package downloads a Chrome build on install. Since you're connecting to a remote browser, you don't need it. puppeteer-core is the client-only package and skips the download entirely — which is exactly the puppeteer browsers install chrome step you're trying to avoid.

Call `disconnect()`, not `close()`. browser.close() terminates the Chrome process. On a hosted runtime, that's the runtime's job. Disconnecting releases your client connection and lets the session be reclaimed according to the runtime's policy.

Use `createBrowserContext()` for isolation. Multiple pages in the same context share cookies and storage. If you're running concurrent jobs, give each one its own context so a login in one task can't leak into another.

What breaks when you go remote

Moving from local to remote Chrome surfaces a handful of failure modes that don't exist locally. Knowing them ahead of time saves a lot of debugging.

Version mismatch between Puppeteer and Chrome. Puppeteer's protocol bindings are generated against a specific Chrome version. A hosted runtime pins its own Chromium build. If the two drift far enough apart, some CDP domains behave unexpectedly. Pin your puppeteer-core version and check the runtime's Chromium version rather than assuming latest works.

`--no-sandbox` and launch flags. Flags like --no-sandbox, --disable-dev-shm-usage, and --window-size are passed at launch. When you connect(), you can't pass them — the browser is already running. If you need specific launch arguments, they have to be configured on the runtime side, not in your Puppeteer code. This is the single most common source of "it worked locally" bugs.

Timeouts on `goto`. Remote navigation includes network latency between your client and the browser, plus the browser's own page load. Set explicit timeouts rather than relying on defaults, and prefer domcontentloaded over networkidle0 for pages with long-polling or analytics beacons that never fully settle.

File downloads. page.waitForEvent('download') works, but the file lands on the *remote* filesystem. You need a way to retrieve it — either a runtime-provided download API or a shared object store. Don't assume the path is local.

Stale sessions. If your process crashes without disconnecting, the session may linger until the runtime's idle timeout reclaims it. Wrap your work in try/finally and always disconnect.

When to use Puppeteer vs Playwright vs raw CDP

Puppeteer isn't the only client for a hosted Chromium session, and it isn't always the right one.

ClientBest forNotes
PuppeteerChrome-only automation, existing Puppeteer codebasesMature, Chrome-focused, puppeteer-core avoids downloads
PlaywrightCross-browser, richer auto-waiting, test runnersconnectOverCDP for Chromium; broader API surface
Raw CDPFine-grained control, custom protocols, non-Node languagesNo abstraction — you manage sessions and targets yourself

If your codebase is already Puppeteer, there's no reason to rewrite it just to go remote. If you're starting fresh and need Firefox or WebKit, Playwright is the better fit — see Playwright connectOverCDP for the equivalent attach flow. And if you're building an agent that needs to inspect network traffic or manipulate the protocol directly, raw CDP over a WebSocket is the lowest-level option.

For agent workloads specifically, the client library is usually the least interesting decision. What matters is whether the runtime gives you persistent profiles, session isolation, and a way to observe what the agent is doing. That's covered in more depth in Remote Browser for AI agents.

Production criteria for a hosted Puppeteer runtime

If you're evaluating runtimes to point Puppeteer at, these are the questions that actually determine whether it works in production.

Does it expose a CDP WebSocket endpoint? This is non-negotiable for Puppeteer. Some services only offer a REST API or a proprietary SDK. If there's no browserWSEndpoint, puppeteer.connect() isn't an option.

How are sessions isolated? Separate browser processes are stronger than separate contexts, which are stronger than separate tabs. Ask which one you get, because it determines whether a crash in one job can take down another.

What happens to state between sessions? Persistent profiles let you keep logins and cookies across runs. Ephemeral sessions start clean every time. Both are valid — but you need to know which you're getting, because it changes how you handle authentication.

Can you observe a running session? A live viewer that streams the browser's screen is the difference between debugging in minutes and debugging in hours. Without it, a failed selector is just a stack trace.

What's the network egress story? Proxies, IP reputation, and geographic location all affect whether a site serves you a real page or a challenge. The runtime should let you configure this rather than forcing a single shared egress.

How is usage metered? Browser time, network traffic, and session count are the usual axes. Check pricing for the current model rather than assuming a flat rate.

Remote Browser exposes CDP endpoints for hosted Chromium sessions, so Puppeteer, Playwright, and Selenium clients all attach the same way. Sessions support persistent profiles, configurable browser settings, and a live viewer for debugging. The documentation covers the connection flow end to end.

A practical migration path

If you have a working local Puppeteer script and want to move it to a hosted runtime, the sequence is short.

  1. Swap `puppeteer` for `puppeteer-core`. Remove the bundled Chrome download. Your code doesn't change.
  2. Replace `launch()` with `connect()`. Pass the runtime's WebSocket endpoint. Move any launch flags you were using into runtime configuration.
  3. Add explicit timeouts. Remote navigation has more latency than local. Set timeout on goto, waitForSelector, and waitForFunction.
  4. Wrap in `try/finally` with `disconnect()`. Never leave a session dangling on a crash path.
  5. Add context isolation for concurrent jobs. One createBrowserContext() per job if you're running in parallel.
  6. Verify downloads and file paths. Anything that touched the local filesystem needs a remote-aware replacement.

Most scripts migrate in an afternoon. The parts that take longer are the ones that depended on local Chrome flags or local filesystem access — those need a runtime-side equivalent, not a code change.

Where this fits

Puppeteer in browser contexts is really a question about where Chrome lives. Once you accept that the browser can be remote and Puppeteer is just a CDP client, the architecture simplifies: your code stays portable, your workers stay thin, and the browser becomes a managed resource with its own lifecycle, profiles, and observability.

That's the same shift that makes agent workloads tractable. An AI agent driving a browser needs the same things a test suite does — isolation, persistence, and a way to see what happened — plus a runtime that can hold a session open across many steps. If you want to see what that looks like without managing Chrome yourself, start with Remote Browser online or connect a session directly from the documentation.