← Blog

BLOG

Playwright Remote Browser WebSocket Endpoint

Connect Playwright to a remote browser WebSocket endpoint over CDP. Learn the handshake, session lifecycle, and production criteria.

October 14, 20269 min readRemote Browser

# Playwright Remote Browser WebSocket Endpoint

A Playwright remote browser WebSocket endpoint is the ws:// or wss:// URL that Playwright uses to attach to a Chromium instance running somewhere other than your machine. You pass it to chromium.connectOverCDP(), and from that point your Playwright code drives a browser it never launched. The endpoint is the entire contract: if it resolves, authenticates, and speaks the Chrome DevTools Protocol, your selectors, waits, and assertions run unchanged. If it does not, you get a connection error before a single page loads.

This guide covers what that endpoint actually is, how the handshake works, how to wire it into a Playwright script, and what to check before you point production traffic at one.

What the WebSocket endpoint actually is

When Chromium starts with --remote-debugging-port=9222, it opens an HTTP server. Hitting http://localhost:9222/json/version returns a JSON blob containing a webSocketDebuggerUrl field. That URL — something like ws://127.0.0.1:9222/devtools/browser/<uuid> — is the browser-level CDP endpoint. Everything Playwright does over connectOverCDP flows through that single socket.

The distinction that trips people up: there are two kinds of CDP endpoints.

  • Browser-level endpoint (/devtools/browser/<id>): one socket, multiplexed across all targets. This is what connectOverCDP wants.
  • Page-level endpoint (/devtools/page/<id>): one socket per tab. Useful for raw CDP clients, not for Playwright's connection model.

If you hand Playwright a page-level URL, the connection may appear to succeed and then behave unpredictably, because Playwright expects to manage targets itself. Always use the browser-level URL.

A hosted runtime abstracts the port and host away. You get a wss:// endpoint that terminates at a session already running Chromium, often behind authentication and a proxy layer. The protocol on the wire is identical; only the transport security and lifecycle management change.

How Playwright connects over CDP

Playwright's connectOverCDP is documented in the Playwright BrowserType API. It accepts either an HTTP endpoint (Playwright fetches /json/version for you) or a direct WebSocket URL. Passing the WebSocket URL directly skips a round trip and avoids ambiguity about which endpoint you meant.

Here is a minimal TypeScript example that connects to a remote endpoint, reuses the existing context, and drives a page:

import { chromium, type Browser, type BrowserContext } from 'playwright';

const ENDPOINT = process.env.REMOTE_BROWSER_WS!; // wss://.../devtools/browser/<id>

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

  // A hosted session usually arrives with a context already open.
  const contexts: BrowserContext[] = browser.contexts();
  const context = contexts[0] ?? (await browser.newContext());

  const page = context.pages()[0] ?? (await context.newPage());

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

  // Do NOT call browser.close() on a shared hosted session unless you
  // intend to terminate it. Disconnect instead.
  await browser.close();
}

run().catch((err) => {
  console.error('connect failed:', err);
  process.exit(1);
});

Three details matter more than the rest of the script.

Context reuse. When you launch Chromium locally, Playwright creates the default context for you. When you connect over CDP, the browser already exists and may already have contexts and pages. Calling newContext() unconditionally can produce a second context that shares nothing with the first — different cookies, different storage. Check browser.contexts() first.

`browser.close()` semantics. Over CDP, close() disconnects the client. Whether it also kills the remote browser depends on the runtime. On a hosted session you typically want to disconnect and let the session's own lifecycle policy handle teardown, or explicitly end the session through the provider's API. Treating close() as "kill the remote browser" is a common source of leaked sessions.

Timeouts. The default connect timeout is generous but not infinite. Network hops to a remote region add latency that a local launch never has. Set an explicit timeout and log the endpoint host on failure so you can tell a DNS problem from an auth problem.

Endpoint URL shapes you will encounter

Not every provider hands you the same URL format. The shape tells you what you are connecting to.

URL shapeWhat it isPlaywright call
ws://127.0.0.1:9222/devtools/browser/<id>Local Chromium, browser-levelconnectOverCDP
wss://host/devtools/browser/<id>Remote Chromium, browser-level, TLSconnectOverCDP
https://host/session/<id>HTTP wrapper; Playwright resolves /json/versionconnectOverCDP
wss://host/session/<id>?token=...Authenticated remote endpointconnectOverCDP
ws://host/devtools/page/<id>Page-level; not for Playwrightraw CDP only

The HTTP wrapper form is convenient because it hides the browser UUID, but it costs an extra request and depends on the provider implementing /json/version correctly. The direct wss:// form is faster and more predictable. If a provider offers both, prefer the WebSocket URL in latency-sensitive paths.

Authentication is usually a query parameter or a header. Playwright's connectOverCDP does not expose arbitrary header injection in all versions, so token-in-query-string is the common pattern. That means the token ends up in logs if you log the endpoint — redact it.

Session lifecycle and why it matters

A local browser lives as long as your process. A remote session lives according to someone else's policy. That gap is where most production bugs hide.

Consider what happens across a long agent run:

  1. Your worker connects to the endpoint.
  2. The agent navigates, fills forms, and extracts data over several minutes.
  3. The worker crashes or the network blips.
  4. The session keeps running — or does not, depending on the runtime.

If the session persists, you can reconnect and resume. If it does not, you have lost cookies, in-flight navigation, and any authenticated state. Persistent profiles and session isolation are the two features that determine which of these you get. A runtime that supports persistent profiles lets a session survive a client disconnect and be reattached later; isolation ensures two concurrent agents never share cookies or storage.

For agent workloads specifically, this is the difference between a task that retries cleanly and one that has to start over. The remote browser for AI agents writeup covers the runtime-layer implications in more depth.

Production criteria for a remote endpoint

Before you commit to an endpoint, evaluate it against these criteria. They are ordered by how often they cause incidents.

Stability of the URL. A WebSocket URL that changes on every request forces you to re-resolve before each connection. That is fine for short tasks and painful for long ones. Prefer endpoints that stay valid for the session's lifetime.

Reconnect behavior. What happens to the session when the socket drops? Does the runtime hold the browser open for a grace period, or tear it down immediately? Ask for the documented behavior, not the marketing answer.

Target visibility. After connecting, does browser.contexts() return the session's real contexts, or an empty array? Some runtimes expose only a fresh context, which breaks any workflow that depends on pre-existing state.

Proxy and network configuration. If your task needs a specific egress region or a residential IP, that has to be configured at the session level, not the Playwright level. Configurable browser settings — proxy, locale, timezone, user agent — should be settable before the endpoint is issued.

Observability. Can you watch the session live while your code runs? A live viewer turns a 40-minute debugging session into a 40-second one. It also lets a human intervene when an agent gets stuck on a CAPTCHA or an unexpected modal.

Usage controls. Per-session time limits, concurrency caps, and a way to see what you have consumed. Without these, a runaway loop becomes an expensive runaway loop. Current limits and metering are listed on the pricing page.

Common failure modes and how to read them

Most connection failures fall into a small number of buckets. Matching the error to the bucket saves time.

  • `ECONNREFUSED` or `ENOTFOUND`: the host is wrong, the session has already ended, or DNS is failing. Check the endpoint is still valid before touching your Playwright code.
  • `401` / `403` during handshake: the token is expired, malformed, or scoped to a different session. Tokens are frequently short-lived.
  • Handshake succeeds, then immediate close: you connected to a page-level endpoint, or the runtime rejected the connection after auth. Verify the URL contains /devtools/browser/.
  • `connectOverCDP` hangs: the endpoint is reachable but not speaking CDP — often a load balancer terminating the upgrade. Test the raw socket with a WebSocket client before blaming Playwright.
  • Contexts empty after connect: the runtime issues a fresh context per connection. Adjust your code to create one, or switch to a runtime that preserves state.

A quick diagnostic: fetch the HTTP form of the endpoint and inspect the JSON. If webSocketDebuggerUrl is present and matches what you are passing, the endpoint is well-formed. If the request 404s, the session is gone.

When a remote endpoint beats a local launch

A local chromium.launch() is simpler and has no network dependency. It is the right choice for a single developer running a test suite on a laptop. It stops being the right choice when any of the following is true:

  • You need to run more browsers than the machine can hold in memory.
  • You need a specific geographic egress IP.
  • You need sessions that outlive the process that created them.
  • You need a human to watch or take over a session in real time.
  • You need consistent browser versions across a fleet of workers.

Each of these is a runtime concern, not a Playwright concern. Playwright is the client; the endpoint is the infrastructure. Keeping that separation clear is what lets you swap runtimes without rewriting automation logic. The remote browser online piece walks through the same trade-off from the infrastructure side.

Wiring it up on Remote Browser

On Remote Browser, a session is created through the API, and you receive a WebSocket endpoint plus a live viewer URL. The endpoint speaks standard CDP, so the TypeScript example above works without modification — you only change the value of REMOTE_BROWSER_WS.

The practical setup steps:

  1. Create a session with the browser settings your task needs (proxy region, locale, persistent profile).
  2. Read the webSocketDebuggerUrl from the session response.
  3. Pass it to chromium.connectOverCDP().
  4. Reuse browser.contexts()[0] rather than creating a new context.
  5. Disconnect when done; end the session explicitly if you want it torn down.

Playwright, Puppeteer, and Selenium all speak CDP, so the same endpoint serves all three. If you are driving an agent rather than a test suite, the connection pattern is identical — the difference is who decides what to click. The documentation covers session creation, profile persistence, and the viewer in detail.

One caveat worth stating plainly: CDP is a Chromium protocol. connectOverCDP works against Chromium and Chromium-based browsers. It does not give you Firefox or WebKit over the same endpoint. If your matrix includes those engines, they need a different connection strategy, and you should plan for that before standardizing on a CDP endpoint.

The short version

The WebSocket endpoint is the seam between your Playwright code and a browser you do not own. Get the URL shape right (browser-level, not page-level), handle context reuse and disconnect semantics deliberately, and evaluate the runtime on session lifecycle rather than on the connection alone. Everything else — selectors, waits, assertions — is unchanged, which is the point. The endpoint is the only thing you have to get right.

Start with a single session, confirm the endpoint resolves and browser.contexts() returns what you expect, then scale the pattern. If you want to see the connection working before you write any code, the remote control browser guide shows the live viewer path, which is often the fastest way to confirm a session is healthy.