BLOG
Puppeteer BrowserWSEndpoint Tutorial: Connect to Remote Chromium
A practical Puppeteer browserWSEndpoint tutorial: connect to remote Chromium over CDP, handle reconnects, and run agents in production.
# Puppeteer BrowserWSEndpoint Tutorial: Connect to Remote Chromium
This Puppeteer browserWSEndpoint tutorial shows you how to connect Puppeteer to a remote Chromium instance instead of launching a local browser. The short version: get a WebSocket debugger URL from your browser provider, pass it to puppeteer.connect({ browserWSEndpoint }), and drive the remote session exactly as you would a local one. The rest of this guide covers what that URL actually is, how to wire it up in TypeScript, and the production details that break naive implementations.
If you already know you want hosted sessions rather than local Chrome, start with Remote Browser for AI agents for the runtime model, then come back here for the Puppeteer specifics.
What browserWSEndpoint Actually Is
browserWSEndpoint is a WebSocket URL that speaks the Chrome DevTools Protocol (CDP). When you launch Chrome with --remote-debugging-port=9222, Chrome exposes an HTTP endpoint at http://localhost:9222/json/version. That response contains a webSocketDebuggerUrl field. That value is your browserWSEndpoint.
Puppeteer's connect() method opens a WebSocket to that URL and speaks CDP over it. Every page.goto(), page.click(), and page.evaluate() call becomes a CDP command on that socket. The browser process can be running anywhere — your laptop, a container, or a managed host — as long as the WebSocket is reachable and the transport is secure.
Two things matter for correctness:
- The endpoint is browser-level, not page-level. You connect once, then call
browser.newPage()orbrowser.pages()to get targets. - The endpoint is a credential. Anyone with the URL can drive the browser. Treat it like a password: never log it, never commit it, always use
wss://in production.
The Chrome DevTools Protocol documentation is the authoritative reference for the commands Puppeteer sends underneath.
Getting a browserWSEndpoint from a Hosted Runtime
With a local Chrome you construct the endpoint yourself. With a hosted runtime you request a session and the provider returns a connection URL. The shape is consistent across providers:
wss://<host>/cdp/<session-id>?token=<short-lived-token>Remote Browser issues a per-session WebSocket URL that you pass straight into Puppeteer. Sessions are isolated, so two concurrent agents never share a browser process, profile, or cookie jar. You can also attach a live viewer to watch the session in real time while your agent runs — useful when a task fails and you need to see why rather than guess from logs.
The practical difference from local Chrome is that you no longer manage the browser binary, the display server, the container image, or the process lifecycle. You manage a URL and a session budget. Current session limits and metering are on the pricing page.
Minimal TypeScript Example
Install Puppeteer and the types:
npm install puppeteer-core
npm install -D typescript @types/nodeUse puppeteer-core rather than puppeteer when connecting to a remote browser. The full puppeteer package downloads a bundled Chromium you will never launch, which adds a large amount of disk usage to your image for no benefit.
import puppeteer, { Browser, Page } from "puppeteer-core";
interface SessionInfo {
browserWSEndpoint: string;
sessionId: string;
}
async function createSession(): Promise<SessionInfo> {
const res = await fetch("https://api.remote-browser.dev/v1/sessions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.REMOTE_BROWSER_API_KEY}`,
},
body: JSON.stringify({
// Configurable browser settings: region, viewport, proxy, profile.
profile: "checkout-agent",
}),
});
if (!res.ok) {
throw new Error(`Session create failed: ${res.status} ${await res.text()}`);
}
return (await res.json()) as SessionInfo;
}
async function run(): Promise<void> {
const session = await createSession();
const browser: Browser = await puppeteer.connect({
browserWSEndpoint: session.browserWSEndpoint,
// Keep the session alive if the socket drops briefly.
protocolTimeout: 180_000,
});
try {
const page: Page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 });
await page.goto("https://example.com", {
waitUntil: "domcontentloaded",
timeout: 30_000,
});
const title = await page.title();
console.log("title:", title);
await page.screenshot({ path: "example.png" });
} finally {
// Disconnect without killing the remote browser.
browser.disconnect();
}
}
run().catch((err) => {
console.error(err);
process.exit(1);
});Two details in that snippet are easy to get wrong.
`browser.disconnect()` versus `browser.close()`. disconnect() closes the WebSocket and leaves the remote browser running. close() sends a CDP command that terminates the browser process. In a hosted runtime you usually want disconnect() and then an explicit session-termination call, so cleanup is idempotent and you do not accidentally kill a session another worker is still using.
`protocolTimeout`. The default is generous but finite. Long-running agent tasks that sit idle between CDP calls can trip it. Raise it deliberately rather than disabling timeouts entirely.
Connecting to an Existing Local Chrome
The same API works against a local browser, which is useful for debugging. Start Chrome with remote debugging enabled:
chrome --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-debugThen fetch the endpoint and connect:
const versionRes = await fetch("http://localhost:9222/json/version");
const { webSocketDebuggerUrl } = await versionRes.json();
const browser = await puppeteer.connect({
browserWSEndpoint: webSocketDebuggerUrl,
});This is the fastest way to confirm your Puppeteer code is correct before you introduce network latency and auth tokens. If it works locally and fails remotely, the problem is almost always the endpoint URL, the token, or a firewall — not your page logic.
Local Chrome vs Hosted Remote Browser
| Dimension | Local Chrome | Hosted remote browser |
|---|---|---|
| Setup | Install binary, manage versions | Request a session, get a URL |
| Scaling | One process per machine, manual | Sessions provisioned per request |
| Isolation | Shared profile unless you configure it | Isolated per session |
| Proxies | Manual config, your own IPs | Configurable per session |
| Debugging | Local DevTools | Live viewer plus CDP |
| Persistence | Local disk, lost on rebuild | Persistent profiles across sessions |
| Cost model | Compute you already pay for | Metered per browser-hour |
| Failure mode | Process crash kills the run | Session ends, reconnect or re-provision |
The trade-off is not "remote is always better." If you run a handful of tests on a laptop, local Chrome is simpler and free. The calculus changes when you need concurrency, consistent IPs, or sessions that survive a deploy. That is the point where managing Chrome becomes a second job.
Production Criteria Before You Commit
Before you move a Puppeteer workload to a hosted runtime, check these against your requirements.
- Reconnect semantics. What happens when the WebSocket drops mid-task? A good runtime lets you reconnect to the same session by ID rather than losing state. Test this by killing the socket deliberately.
- Profile persistence. If your agent logs in, does the session keep cookies and local storage across runs? Persistent profiles matter for anything behind auth.
- Proxy and network controls. Per-session proxy configuration, including residential IPs where needed, is often the difference between a task succeeding and getting blocked.
- Isolation guarantees. Confirm that sessions do not share a browser process or profile. Shared state between agents is a correctness bug, not a performance optimization.
- Observability. A live viewer plus structured session logs turns a 40-minute debugging session into a 4-minute one.
- Metering transparency. Know how browser-hours are counted and whether idle time bills. See pricing for the current model.
If you are evaluating this for an agent stack rather than a test suite, the remote browser guide covers the runtime layer in more depth.
Common Failure Modes
`Error: Failed to launch the browser process` — you called puppeteer.launch() instead of puppeteer.connect(). Check your import and call site.
`WebSocket connection closed abnormally` — usually a token expiry or a proxy stripping the Upgrade header. Verify the token lifetime and that nothing between your worker and the runtime terminates long-lived connections.
`Target closed` mid-task — the remote browser was reaped, often because the session hit a time limit or the worker that created it exited. Decouple session lifetime from worker lifetime.
Hangs with no error — a CDP command was sent but the response never arrived. This is frequently a protocolTimeout that is too low combined with a slow page. Log the last command issued before the hang.
Works locally, fails in CI — the CI runner cannot reach the WebSocket host, or the token is not injected. Print the host portion of the endpoint (never the token) to confirm.
Where This Fits in an Agent Stack
Puppeteer over browserWSEndpoint is the lowest-level way to drive a remote browser. It gives you full CDP access and no abstraction. That is an advantage when you need precise control over network interception, request blocking, or performance tracing, and a disadvantage when you would rather describe a task in natural language.
Most production agent stacks end up with both: a high-level agent loop for task planning, and a Puppeteer or Playwright layer for the deterministic steps — login, checkout, file upload — where you want exact control. The remote runtime is shared between them, which means one place to configure proxies, profiles, and isolation.
If you are coming from Playwright rather than Puppeteer, the connection model is nearly identical; the remote control browser guide maps the concepts across both libraries.
Practical Checklist
- Use
puppeteer-core, notpuppeteer, when connecting remotely. - Request a session, take the
browserWSEndpoint, and pass it toconnect(). - Set
protocolTimeoutbased on your longest expected idle gap. - Call
disconnect()in afinallyblock; terminate the session explicitly. - Never log or commit the endpoint URL — it is a credential.
- Test reconnect behavior before you rely on it.
- Verify isolation and profile persistence against your actual workload.
- Check current session limits and metering on pricing.
The API surface is small. The operational details are where remote Puppeteer succeeds or fails, and they are worth getting right before you scale past a single worker. For the full connection reference, see the documentation.