← Blog

BLOG

Playwright connectOverCDP GitHub: Connect to Remote Chrome

Learn how Playwright connectOverCDP works, what to look for in GitHub examples, and how to wire it to a hosted Chromium runtime for production.

September 17, 20269 min readRemote Browser

# Playwright connectOverCDP GitHub: Connect to Remote Chrome

If you searched for playwright connectovercdp github, you probably want one of two things: a working code example, or a repo that shows how to attach Playwright to a Chrome instance that is already running somewhere else. The short answer is that browserType.connectOverCDP() takes a WebSocket debugger URL and returns a Browser object you drive exactly like a locally launched one. The longer answer is that most GitHub examples stop at localhost:9222, and that is not the part that breaks in production.

This guide covers the API contract, what real GitHub examples get right and wrong, and how to point connectOverCDP at a hosted Chromium session so you are not maintaining Chrome, drivers, and profiles on every worker.

What connectOverCDP actually does

connectOverCDP is a method on BrowserType in Playwright. It opens a connection to a browser over the Chrome DevTools Protocol and returns a Browser instance. From that point on, the Playwright API is the same: browser.contexts(), context.newPage(), page.click(), and so on.

The important distinction is that you are not launching a browser. You are attaching to one that already exists. That means:

  • You do not control the browser binary version through Playwright's install step.
  • You do not get a fresh profile unless the remote side gives you one.
  • You inherit whatever contexts and pages are already open.
  • Lifecycle is shared — closing the connection is not the same as killing the browser.

This is why connectOverCDP is the right primitive for remote and hosted browsers, and why it behaves differently from chromium.launch() in ways that matter once you leave your laptop.

The API shape

const browser = await chromium.connectOverCDP(endpointURL, options);

endpointURL is either an HTTP URL (Playwright will discover the WebSocket URL from /json/version) or a ws:// / wss:// debugger URL directly. options accepts things like headers, timeout, and slowMo.

A minimal working example against a remote endpoint:

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

async function runTask(cdpEndpoint: string) {
  const browser: Browser = await chromium.connectOverCDP(cdpEndpoint, {
    timeout: 30_000,
  });

  // A hosted session usually hands you one context already.
  const context: BrowserContext =
    browser.contexts()[0] ?? (await browser.newContext());

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

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

  // Detach. Do not assume this kills the remote browser.
  await browser.close();
}

runTask(process.env.CDP_ENDPOINT!);

Two details worth internalizing. First, browser.contexts()[0] — a remote session often arrives with a context already created, and calling newContext() blindly can leave you with an orphaned blank context. Second, browser.close() on a CDP connection disconnects; whether the remote browser shuts down depends on the provider. Check that behavior before you build cleanup logic around it.

What GitHub examples get right and wrong

Searching GitHub for connectOverCDP returns a lot of near-identical snippets. They are useful as a starting point and misleading as a production reference. Here is the pattern you will see most often:

const browser = await chromium.connectOverCDP('http://localhost:9222');

That works if you launched Chrome with --remote-debugging-port=9222 on the same machine. It tells you nothing about authentication, TLS, session lifetime, or what happens when the endpoint is behind a load balancer.

ConcernTypical GitHub snippetProduction requirement
Endpointhttp://localhost:9222wss:// URL with auth, from a session API
Chrome binaryAssumed installedManaged by the runtime, versioned
ProfileDefault user profileIsolated per session, optionally persistent
AuthNoneToken or signed URL in headers
Lifecyclebrowser.close()Explicit session teardown via API
ConcurrencyOne browserPooled sessions, per-session isolation
Failure modeCrashReconnect, retry, or new session
Observabilityconsole.logLive viewer, session logs, traces

The gap is not the Playwright call. The gap is everything around it. If you are evaluating a GitHub repo as a template, check whether it handles the right column at all. Most do not, because the authors were demonstrating the API, not operating it.

The --remote-debugging-port trap

A common GitHub pattern is to shell out to Chrome with --remote-debugging-port=9222 and then connect. This works locally and fails in almost every hosted environment for three reasons:

  1. Binding. Chrome binds the debug port to 127.0.0.1 by default. Exposing it requires --remote-debugging-address=0.0.0.0, which is a security decision, not a convenience flag.
  2. No auth. The DevTools Protocol has no built-in authentication. Anyone who can reach the port can drive the browser. On a public network that is a full compromise.
  3. Version drift. The Chrome you install on a worker is not the Chrome the next worker installs. CDP surface area changes between versions.

Hosted runtimes solve this by giving you a wss:// endpoint with a token, a pinned Chromium build, and a session that is destroyed on teardown. That is the model to look for when you graduate from a GitHub snippet.

Wiring connectOverCDP to a hosted runtime

The practical flow with a hosted Chromium runtime is: create a session over HTTP, receive a CDP endpoint, connect Playwright to it, run your task, then release the session. The Playwright code does not change — only where the endpoint comes from.

import { chromium } from 'playwright';

type Session = {
  id: string;
  cdpUrl: string; // wss://... provided by the runtime
};

async function createSession(): Promise<Session> {
  const res = await fetch('https://api.remote-browser.dev/v1/sessions', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${process.env.REMOTE_BROWSER_TOKEN}`,
    },
    body: JSON.stringify({
      // configurable browser settings, not hardcoded fingerprints
      profile: { persistent: true, name: 'checkout-agent' },
      viewport: { width: 1440, height: 900 },
    }),
  });
  if (!res.ok) throw new Error(`session create failed: ${res.status}`);
  return (await res.json()) as Session;
}

async function releaseSession(id: string) {
  await fetch(`https://api.remote-browser.dev/v1/sessions/${id}`, {
    method: 'DELETE',
    headers: { Authorization: `Bearer ${process.env.REMOTE_BROWSER_TOKEN}` },
  });
}

async function main() {
  const session = await createSession();
  const browser = await chromium.connectOverCDP(session.cdpUrl, {
    timeout: 30_000,
  });

  try {
    const context = browser.contexts()[0] ?? (await browser.newContext());
    const page = context.pages()[0] ?? (await context.newPage());
    await page.goto('https://example.com');
    // ... your task
  } finally {
    await browser.close();
    await releaseSession(session.id);
  }
}

main();

The endpoint shape and session API differ by provider. What matters is that the endpoint is authenticated, the session has a defined lifetime, and teardown is explicit. You can read more about the runtime model in Remote Browser for AI agents.

Why persistent profiles matter here

With connectOverCDP, the profile is whatever the remote browser already has. If you want cookies, localStorage, and logged-in state to survive across sessions, the runtime has to persist them. If you want a clean slate every time, it has to isolate them. Either way, that is a runtime decision, not a Playwright one — connectOverCDP gives you no profile control beyond what the endpoint exposes.

For agents that log in once and then run many tasks, persistent profiles cut a large amount of redundant work. For agents that must not leak state between tenants, isolation is the requirement. Both are configuration, not code.

Production criteria for a CDP endpoint

Once you move past GitHub snippets, evaluate the endpoint against these criteria:

  • Transport. wss:// with TLS, not plain ws://. The DevTools Protocol is fully privileged.
  • Auth. A token or signed URL per session. Rotate it. Do not share one endpoint across tenants.
  • Isolation. One browser process per session, or at minimum one context per tenant with no shared storage.
  • Version pinning. A known Chromium build so CDP behavior is stable across deploys.
  • Lifecycle. A session ID you can query and destroy. browser.close() alone is not enough.
  • Observability. A live viewer or session recording so you can debug a failed run without reproducing it.
  • Network controls. Proxy configuration and browser settings you can set per session, since IP reputation affects success rates on protected sites.
  • Reconnect semantics. What happens if the WebSocket drops mid-task. A good runtime lets you reconnect to the same session.

If a provider cannot answer these, you are back to running Chrome on your own workers — which is fine, but then you own the --remote-debugging-port problem described above.

Common failure modes and how to debug them

`connectOverCDP` times out. Usually the endpoint is reachable but the WebSocket upgrade is blocked by a proxy or firewall. Test with curl -i <http-endpoint>/json/version first; if that returns JSON, the HTTP side is fine and the issue is the upgrade path.

Connection succeeds but `browser.contexts()` is empty. The remote browser has no context yet. Call newContext(). Do not assume a context exists.

Pages close unexpectedly. Something else is driving the same browser. With CDP, multiple clients can attach. If your runtime allows it, avoid sharing a session across processes.

Navigation hangs. Check whether the remote browser has network access to the target. A hosted session may have different egress rules than your local machine.

`browser.close()` does not stop billing. It should not — closing a CDP connection is a detach. Release the session through the provider's API. See pricing for how sessions are metered.

Version mismatch errors on newer CDP methods. The remote Chromium is older than your Playwright version expects. Pin both, or use a runtime that tracks recent Chromium releases.

When to use connectOverCDP vs launch

Use chromium.launch() when the browser runs on the same machine as your code and you control the environment. Use connectOverCDP when the browser runs somewhere else — a container, a VM, or a hosted runtime.

The trade-off is control versus operational cost. launch() gives you full control over flags, binary, and profile, at the cost of installing and maintaining Chrome everywhere your code runs. connectOverCDP gives you a stable endpoint and offloads that maintenance, at the cost of depending on the endpoint's behavior and lifecycle.

For AI agents and long-running automation, the second model usually wins because the hard part is not the Playwright call — it is keeping browsers alive, isolated, and debuggable across many concurrent tasks. That is the problem a hosted runtime solves. You can see the broader picture in Remote Browser online and remote control browser.

Practical checklist

Before you ship a connectOverCDP integration:

  1. Confirm the endpoint is wss:// and authenticated.
  2. Handle the case where browser.contexts() is empty.
  3. Treat browser.close() as a detach, not a teardown.
  4. Release sessions explicitly through the provider API.
  5. Pin your Playwright version and know the remote Chromium version.
  6. Add a reconnect path for dropped WebSockets.
  7. Log the session ID with every task so failures are traceable.
  8. Test against the actual target sites, not just example.com.

The Playwright API is the easy part. The runtime is where reliability is won or lost. For the API reference, see the Playwright CDP documentation and the Chrome DevTools Protocol spec. For the runtime side, start with the documentation.