← Blog

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.

September 20, 20268 min readRemote Browser

# 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() or browser.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/node

Use 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-debug

Then 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

DimensionLocal ChromeHosted remote browser
SetupInstall binary, manage versionsRequest a session, get a URL
ScalingOne process per machine, manualSessions provisioned per request
IsolationShared profile unless you configure itIsolated per session
ProxiesManual config, your own IPsConfigurable per session
DebuggingLocal DevToolsLive viewer plus CDP
PersistenceLocal disk, lost on rebuildPersistent profiles across sessions
Cost modelCompute you already pay forMetered per browser-hour
Failure modeProcess crash kills the runSession 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

  1. Use puppeteer-core, not puppeteer, when connecting remotely.
  2. Request a session, take the browserWSEndpoint, and pass it to connect().
  3. Set protocolTimeout based on your longest expected idle gap.
  4. Call disconnect() in a finally block; terminate the session explicitly.
  5. Never log or commit the endpoint URL — it is a credential.
  6. Test reconnect behavior before you rely on it.
  7. Verify isolation and profile persistence against your actual workload.
  8. 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.