DEV Community

RemoteBrowser
RemoteBrowser

Posted on Originally published at remote-browser.dev

Puppeteer BrowserWSEndpoint Example: Connect to Remote Chromium

Puppeteer BrowserWSEndpoint Example: Connect to Remote Chromium

If you are searching for a Puppeteer browserWSEndpoint example, you probably already have a WebSocket URL and want to know exactly how to hand it to Puppeteer so your script drives a browser that is not running on your machine. The short answer: pass the endpoint to puppeteer.connect() instead of puppeteer.launch(), then call browser.newPage() as usual. Everything after that — pages, selectors, network interception — behaves the same as a local launch, because browserWSEndpoint is just the Chrome DevTools Protocol (CDP) WebSocket address of a running browser.

This post gives you a working example, explains what the endpoint actually is, and covers the production details that trip people up: version matching, session lifetime, reconnection, and when a WebSocket endpoint is the wrong abstraction.

What browserWSEndpoint actually is

When Chromium starts with remote debugging enabled, it opens a WebSocket server and prints a line like:

DevTools listening on ws://127.0.0.1:9222/devtools/browser/6f3c...
Enter fullscreen mode Exit fullscreen mode

That URL is the browser-level CDP endpoint. It is not a page, not a tab, and not a session — it is the control channel for the whole browser process. Puppeteer's browserWSEndpoint option is simply the string it uses to open that channel.

Two things follow from this:

  • The endpoint is transport, not state. Connecting to it does not create a browser; it attaches to one that already exists. If the process dies, your endpoint is dead too.
  • The endpoint is version-sensitive. CDP is a moving protocol. Puppeteer ships with a pinned Chromium and a matching protocol definition. Connecting a Puppeteer version to a Chromium build several major versions away is the single most common source of "it connected but nothing works" bugs.

For a hosted runtime, the endpoint is issued per session. You request a session, receive a ws:// or wss:// URL, connect, do your work, and release the session. That lifecycle is the part worth designing around.

A minimal Puppeteer browserWSEndpoint example

Here is the smallest useful version. It assumes you already have an endpoint string from your browser provider or from a locally launched Chrome with --remote-debugging-port.

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

const endpoint = process.env.BROWSER_WS_ENDPOINT;
if (!endpoint) throw new Error('BROWSER_WS_ENDPOINT is not set');

async function main() {
  const browser: Browser = await puppeteer.connect({
    browserWSEndpoint: endpoint,
    // Keep the remote browser alive when the client disconnects.
    // Set false if you want the session torn down with the socket.
    defaultViewport: { width: 1280, height: 800 },
  });

  const page: Page = await browser.newPage();
  await page.goto('https://example.com', { waitUntil: 'domcontentloaded' });

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

  // Disconnect, do not close. close() would kill the remote browser.
  await browser.disconnect();
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});
Enter fullscreen mode Exit fullscreen mode

Three details in that snippet matter more than they look:

  1. puppeteer-core, not puppeteer. If you are connecting to a browser you did not download, you do not need the bundled Chromium. puppeteer-core skips the ~150 MB download and avoids version drift between the bundled binary and the remote one.
  2. browser.disconnect() vs browser.close(). disconnect() drops the WebSocket and leaves the browser running. close() sends a protocol command that shuts the browser down. On a metered hosted runtime, calling close() when you meant disconnect() will end your session — which is sometimes what you want, and sometimes a bug that kills a long-running agent mid-task.
  3. defaultViewport is a client-side hint. It sets the viewport Puppeteer applies to new pages. It does not resize the remote browser window. If your provider exposes viewport configuration, set it there too.

Connecting with Playwright over CDP instead

Puppeteer is not the only client for a CDP endpoint. Playwright's connectOverCDP accepts the same WebSocket URL, which is useful if your test suite is already Playwright-based but your runtime is a hosted Chromium session.

import { chromium, Browser, Page } from 'playwright';

const endpoint = process.env.BROWSER_WS_ENDPOINT!;

async function run(): Promise<void> {
  const browser: Browser = await chromium.connectOverCDP(endpoint, {
    timeout: 30_000,
  });

  // Reuse the existing context if the session already has one.
  const context = browser.contexts()[0] ?? (await browser.newContext());
  const page: Page = await context.newPage();

  await page.goto('https://example.com', { waitUntil: 'domcontentloaded' });
  console.log(await page.title());

  await browser.close(); // For connectOverCDP this disconnects the client.
}

run();
Enter fullscreen mode Exit fullscreen mode

The trade-off is real. Playwright over CDP gives you Playwright's locators, auto-waiting, and tracing, but it does not give you Playwright's browser patching — the instrumentation Playwright normally injects at launch. Some features behave differently when attached to an externally managed browser. If your workflow depends on Playwright-specific internals, test them against the remote endpoint before committing. The Playwright CDP documentation is the authoritative reference for what connectOverCDP does and does not support.

Puppeteer vs Playwright over a WebSocket endpoint

Concern Puppeteer connect() Playwright connectOverCDP()
Protocol CDP only CDP only (for Chromium)
Browser patching None None when attaching
Auto-waiting Manual / waitForSelector Built into locators
Tracing Not built in Available, with caveats
Version coupling Tight to Chromium build Tolerant but not immune
Best fit Chrome-centric scripts, existing Puppeteer code Test suites, teams already on Playwright

Neither is "better." If you have a working Puppeteer codebase, puppeteer.connect() is a two-line change. If you are starting fresh and want tracing and resilient locators, Playwright is the more ergonomic client — just verify the features you rely on actually work over CDP.

Production criteria for a remote endpoint

A browserWSEndpoint example gets you connected. Keeping it connected under load is a different problem. These are the criteria that matter when you move from a laptop to a deployed agent.

Session lifetime and idle timeouts. Hosted sessions are usually metered and expire. Know your idle timeout and whether the clock resets on activity. An agent that pauses for a model call longer than the idle window will come back to a dead socket.

Reconnection semantics. WebSockets drop. Plan for it: catch the disconnect, request a new session, and decide whether you need to restore state (cookies, storage, open tabs) or can restart the task. Persistent profiles help here — if the runtime supports them, session state survives across connections.

Version pinning. Ask what Chromium build the runtime serves and whether you can pin it. If your Puppeteer version expects CDP methods the remote build does not implement, you get silent failures. Pin both sides.

Proxy and network configuration. Many production workflows need specific egress IPs, geolocation, or header handling. Check whether the runtime exposes proxy configuration per session and whether it is configurable at the session level or only globally.

Observability. When a remote task fails, you need to see what the browser saw. A live viewer or session recording turns a 40-minute debugging session into a 40-second one. This is the difference between a hosted browser you can operate and one you can only hope works.

Isolation. Sessions should not share cookies, storage, or process state unless you explicitly want them to. Verify this rather than assuming it.

For a broader look at how these criteria map onto agent workloads, see Remote Browser for AI Agents.

Common failure modes

"Protocol error (Target.setAutoAttach): Target closed." Usually the session expired or the browser crashed. Check session lifetime before debugging your script.

Connection succeeds, newPage() hangs. Often a version mismatch or a session that is already at its page limit. Log the browser version from browser.version() and compare it to your Puppeteer's expected Chromium.

Works locally, fails remotely. Local Chrome and hosted Chromium differ in flags, extensions, and sometimes headless mode. Anything that depends on a specific launch flag needs to be configured on the runtime side, not passed to connect()connect() has no launch options.

Endpoint works once, then 401s. Many providers issue single-use or short-lived endpoints. Request a fresh endpoint per connection rather than caching the string.

When a WebSocket endpoint is the wrong tool

browserWSEndpoint is the right abstraction when you want to drive a browser with code you already have. It is the wrong abstraction when:

  • You want a managed task API that takes a natural-language goal and returns a result. That is an agent runtime, not a CDP endpoint.
  • You need the browser to outlive your process by hours or days without a client attached. Look for session persistence rather than a socket.
  • You are running thousands of short tasks and want the runtime to handle pooling. Managing your own connection pool over raw WebSockets is possible but rarely worth it.

If you are still deciding between a raw endpoint and a higher-level API, Remote Browser Online walks through the options.

Getting an endpoint and running the example

The example above works against any CDP-compatible endpoint. To run it against a hosted Chromium session:

  1. Create a session through the API and read the WebSocket URL from the response.
  2. Export it as BROWSER_WS_ENDPOINT.
  3. Run the script with puppeteer-core installed.
  4. Release the session when the task finishes — do not rely on the socket closing to clean up.

The documentation covers session creation, endpoint formats, and the connection lifecycle in detail. Current session limits and metering are on the pricing page.

One practical note: treat the endpoint as a credential. It grants full control of the browser, including any authenticated sessions inside it. Do not log it, do not commit it, and rotate it per session where the provider supports it.

Summary

A Puppeteer browserWSEndpoint example is short — puppeteer.connect({ browserWSEndpoint }) and you are driving a remote browser. The engineering is in everything around it: matching Puppeteer to the remote Chromium version, distinguishing disconnect() from close(), handling session expiry, and choosing a runtime that gives you the observability and isolation you need. Get those right and the endpoint becomes an implementation detail. Get them wrong and you will spend your time debugging sockets instead of shipping the task.

Top comments (0)