BLOG
Puppeteer Remote Browser Chrome: Connect and Run in the Cloud
Learn how to run Puppeteer against a remote browser Chrome instance, wire browserWSEndpoint, and manage browser versions without local installs.
# Puppeteer Remote Browser Chrome
Running Puppeteer against a remote browser Chrome instance means your Node process no longer launches a local binary. Instead, it connects to a Chromium process running elsewhere over the Chrome DevTools Protocol (CDP). This is the pattern behind most production browser automation today: the script stays small, the browser becomes infrastructure, and you stop shipping a large Chrome download with every deploy. This guide covers how puppeteer.connect() works with a browserWSEndpoint, how to get that endpoint, how browser versions and launch args behave when the browser is remote, and what to check before you put it in production.
If you already know you want a hosted runtime, you can skip ahead to Remote Browser for AI agents for the architectural picture. The rest of this post is the Puppeteer-specific mechanics.
What "remote browser Chrome" actually means in Puppeteer
Puppeteer has two entry points:
puppeteer.launch()— spawns a local Chromium/Chrome process and connects to it over a local pipe or WebSocket.puppeteer.connect()— attaches to an *already running* browser via a WebSocket URL.
A remote browser is just the second case where the WebSocket URL points at a machine that isn't yours. The browser could be a container in your own cluster, a VM you manage, or a hosted service. Puppeteer doesn't care about the topology; it only cares that the endpoint speaks CDP.
That distinction matters because almost every operational difference between "local Puppeteer" and "remote Puppeteer" comes from the fact that you no longer control the process lifecycle. You don't call browser.close() to kill a machine you don't own — you disconnect. You don't pass args to a process that's already running — those were set at launch time by whoever runs the browser. Understanding that split is the whole game.
Getting a browserWSEndpoint
The browserWSEndpoint is a ws:// or wss:// URL that Puppeteer uses to open a CDP session. There are three common ways to obtain one.
1. From a local launch. If you launch Chrome yourself with --remote-debugging-port=9222, you can read the endpoint from http://localhost:9222/json/version:
curl -s http://localhost:9222/json/version | jq -r '.webSocketDebuggerUrl'This is useful for development, but it's not a remote browser — it's your own machine with an extra step.
2. From a self-hosted container. You run Chromium in Docker with a debugging port exposed, then construct the endpoint from the host and port. You own patching, scaling, session cleanup, and outbound network configuration.
3. From a hosted runtime. You request a session and the provider returns a ready-to-use browserWSEndpoint (often alongside a CDP URL and a live viewer link). This is the model Remote Browser uses: you get a connection string, paste it into puppeteer.connect(), and the browser is already running with the settings you requested. See Remote Browser online for how session provisioning works end to end.
The important property is that all three produce the same kind of URL. Your Puppeteer code doesn't change between them, which is why migrating from local to remote is usually a config change rather than a rewrite.
Connecting Puppeteer to a remote browser
Here is the minimal TypeScript pattern. It uses Playwright's CDP entry point because it handles reconnection and typed sessions well, but the same endpoint works with puppeteer.connect() directly.
import { chromium } from "playwright";
type SessionInfo = {
browserWSEndpoint: string;
cdpUrl: string;
};
async function runTask(session: SessionInfo) {
// Connect over CDP to the remote Chromium instance.
const browser = await chromium.connectOverCDP(session.cdpUrl, {
timeout: 30_000,
});
// A remote session usually starts with one existing context.
const context = browser.contexts()[0] ?? (await browser.newContext());
const page = await context.newPage();
try {
await page.goto("https://example.com", { waitUntil: "domcontentloaded" });
const title = await page.title();
console.log("title:", title);
// Do work here: fill forms, extract data, run assertions.
} finally {
// Disconnect, do not kill the remote browser unless you own it.
await page.close();
await browser.close();
}
}Two details are easy to get wrong.
First, browser.close() on a CDP connection disconnects the client. Whether it also terminates the remote browser depends on the provider and the session semantics. With a hosted runtime, you typically want to disconnect and let the session expire or be explicitly stopped through the API. Check your provider's docs — for Remote Browser, session lifecycle is covered in the documentation.
Second, if you're using Puppeteer directly rather than Playwright, the call is:
import puppeteer from "puppeteer";
const browser = await puppeteer.connect({
browserWSEndpoint: session.browserWSEndpoint,
defaultViewport: null,
});defaultViewport: null matters when the remote browser already has a viewport configured. Forcing a viewport from the client can fight with the server-side setting and produce inconsistent screenshots.
Puppeteer browser versions and the install problem
Locally, puppeteer downloads a pinned Chrome build into a cache directory. That's what puppeteer browsers install chrome does — it fetches a specific revision and records it. The puppeteer browsers command family (also exposed through the puppeteer npm package) manages that cache: list, install, and remove browser builds.
This is convenient until it isn't. The problems are predictable:
- Image size. Every CI runner and container needs the browser binary. That adds significant size to each image.
- Version drift. Your
package.jsonpins a Puppeteer version, which pins a Chrome revision. When you upgrade Puppeteer, you may silently change the browser your tests run against. - Platform mismatch. The Chrome build that works on your laptop may not match the architecture or libc of your production container.
- Cold starts. Downloading and unpacking Chrome on every fresh worker adds latency you can't easily hide.
A remote browser inverts this. The browser version is a property of the *server*, not your client. You choose a version when you request a session, and your Puppeteer client just connects. There's no puppeteer browsers install chrome step in your build, no cache to warm, and no binary in your image.
The trade-off is that you now depend on the provider's version catalog. If you need a very specific Chrome build for a compatibility test, confirm it's available before you commit. For most automation and agent workloads, "a recent stable Chromium" is fine, and the operational savings dominate.
| Concern | Local Puppeteer | Remote browser Chrome |
|---|---|---|
| Browser binary | Downloaded per machine/image | Runs server-side |
| Version control | Pinned by Puppeteer release | Selected per session |
| Launch args | You pass them to launch() | Set by the runtime at launch |
| Scaling | You manage processes and memory | Provider manages capacity |
| Session cleanup | browser.close() kills the process | Disconnect; session ends via API |
| Proxy / network | Your infra | Configurable per session |
| Debugging | Local DevTools | Live viewer + CDP |
Puppeteer browser args when the browser is remote
puppeteer.launch({ args: [...] }) is one of the most-used Puppeteer APIs, and it's the one that changes most when you go remote. Those args are passed to the Chrome process at startup. If the process is already running on someone else's machine, you can't inject new args from the client.
In practice this means:
- Flags you can still influence from the client are limited to CDP-level behavior — things like viewport, emulation, and network interception, which are set through the protocol rather than process flags.
- Flags that must be set at launch — sandbox settings, headless mode, GPU flags, proxy configuration, and similar — have to be exposed by the runtime as session options.
So the question to ask any remote browser provider is: *which launch args can I configure per session?* A runtime that only gives you a fixed browser is fine for simple scraping but painful when you need a specific proxy, a particular user agent at the process level, or a non-default headless mode.
Remote Browser exposes configurable browser settings per session, including proxy and network options, rather than a fixed binary. If you need a specific combination, verify it against the documentation before designing around it.
Production criteria for a remote Puppeteer setup
Once the connection works, the interesting questions are operational. Here's a checklist that separates a demo from something you'd run on a schedule.
Session isolation. Each task should get its own browser context or its own browser, so cookies and storage from one job don't leak into another. Shared browsers with shared profiles are a common source of flaky, hard-to-reproduce bugs.
Persistent profiles when you need them. Some workflows — logged-in dashboards, multi-step flows — need state to survive across sessions. Others need a clean slate every time. A good runtime lets you choose, rather than forcing one model.
Reconnection behavior. WebSocket connections drop. Your client should handle a closed CDP socket by reconnecting or failing fast, not by hanging. Set explicit timeouts on connectOverCDP and on navigation.
Observability. When a remote task fails, you want to see what the browser saw. A live viewer and session recordings turn "it failed" into "here's the frame where the selector didn't match." See Remote control browser for how live session control fits into debugging.
Cost model. Remote browsers are usually billed by session time. That means idle sessions cost money, and a task that hangs on a waitForSelector with no timeout is a billing bug as much as a logic bug. Always set timeouts. Current rates are on the pricing page.
Network egress. If your automation pulls large payloads, egress can matter as much as browser time. Know what's metered.
When local Puppeteer is still the right call
Remote isn't automatically better. Local Puppeteer is the right choice when:
- You're developing and iterating on selectors, where the feedback loop of a local browser is faster.
- You need a browser build that isn't available remotely.
- Your workload is small, infrequent, and runs on a machine that already has Chrome.
- You're debugging a CDP-level issue and want full control of the process.
The migration path that works well is to keep local Puppeteer for development and point the same code at a remote endpoint in CI and production. Because the only difference is the connection URL, you can gate it on an environment variable:
const endpoint = process.env.BROWSER_WS_ENDPOINT;
const browser = endpoint
? await puppeteer.connect({ browserWSEndpoint: endpoint })
: await puppeteer.launch({ headless: true });That single branch lets you develop locally and run remotely without maintaining two code paths.
Where this fits for AI agents
Puppeteer remote browser setups are increasingly the substrate for AI browser agents rather than hand-written scripts. The reason is the same as above: agents need browsers that start fast, isolate cleanly, and can be observed when they go wrong. An agent that runs a hundred steps and fails at step ninety is only debuggable if you can replay the session.
If that's your use case, the Puppeteer mechanics in this post still apply — the agent framework ultimately drives a CDP connection — but the runtime requirements are stricter. Persistent profiles, proxy configuration, and live viewing move from "nice to have" to "required." Remote web browser covers the runtime side of that in more depth.
The practical takeaway: learn puppeteer.connect() and browserWSEndpoint once, and you can point your automation at a local Chrome, a container you run, or a hosted runtime without changing the code that does the actual work. The browser becomes a connection string, and everything above it stays portable.
For the CDP details underneath, the Chrome DevTools Protocol documentation is the authoritative reference, and Playwright's connectOverCDP behavior is documented in the Playwright docs.