On this page

Playwright Connect Over CDP: Attach to a Real Profile

playwrightconnect-over-cdpchrome

Playwright connect over CDP means launching Chrome yourself with --remote-debugging-port and a separate --user-data-dir, then calling connect_over_cdp with the endpoint URL, such as http://localhost:9222. Chrome 136 and later ignore the port flag on the default profile directory, so never point it there.

Your Chrome window is already open: logged in, extensions installed, a checkout flow half finished. Call launch() and Playwright starts a clean browser with none of it. Playwright connect over CDP fixes that, because, per Playwright’s BrowserType reference, connect_over_cdp attaches to the browser that’s already running.

This is for QA and automation engineers, and for anyone wiring up an AI agent. You’ll attach, drive a page, detach and fix the usual failures, using copy-pasteable Python (sync and async) and TypeScript.

Key Takeaways

  • Per Playwright’s BrowserContext docs, storageState is not a profile: it holds cookies and localStorage, not extensions, history or fingerprint.
  • A new context on an attached browser starts empty; your logged-in state lives in the default context.
  • Playwright’s BrowserType docs call connect_over_cdp lower fidelity than Playwright’s own protocol, so run your script both ways.

How do you use Playwright connect over CDP, step by step?

Using Playwright connect over CDP takes five steps: start Chrome with a debugging port and its own profile folder, check the endpoint, connect, drive a page, then end your script without killing Chrome.

Image generated

Playwright’s BrowserType docs say the class provides methods to “launch a specific browser instance or connect to an existing one.” This is the second case.

  1. Start Chrome with a separate profile folder. Replace <CHROME_PATH> with the Chrome executable on your system, and point --user-data-dir at a new, empty folder:

    "<CHROME_PATH>" --remote-debugging-port=9222 --user-data-dir="$HOME/chrome-cdp-profile"

On Windows, use a folder path such as C:\chrome-cdp-profile. Reusing your everyday profile folder is the usual mistake here, and it is why the port stays closed on recent Chrome builds.

  1. Verify the endpoint. In a second terminal, run curl http://127.0.0.1:9222/json/version. A live endpoint prints JSON that includes a webSocketDebuggerUrl field. “Connection refused” means Chrome isn’t listening on that port yet, so fix this before you touch Playwright.

  2. Connect. Pass the HTTP URL as the first positional argument. Install Playwright, record your version with pip show playwright or npm ls playwright, and read that version’s release notes.

  3. Drive a page. A freshly attached Chrome already has a context, so reuse contexts[0] and its first page instead of opening a second context.

  4. Clean up. End the script without closing Chrome, as described below.

Python, sync:

from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.connect_over_cdp("http://127.0.0.1:9222")
    context = browser.contexts[0]
    page = context.pages[0] if context.pages else context.new_page()
    page.goto("https://example.com")
    print(page.title())

Python, async:

import asyncio
from playwright.async_api import async_playwright

async def main():
    async with async_playwright() as p:
        browser = await p.chromium.connect_over_cdp("http://127.0.0.1:9222")
        context = browser.contexts[0]
        page = context.pages[0] if context.pages else await context.new_page()
        await page.goto("https://example.com")
        print(await page.title())

asyncio.run(main())

TypeScript:

import { chromium } from "playwright";

(async () => {
  const browser = await chromium.connectOverCDP("http://127.0.0.1:9222");
  const context = browser.contexts()[0];
  const page = context.pages()[0] ?? (await context.newPage());
  await page.goto("https://example.com");
  console.log(await page.title());
  process.exit(0); // ends Node without calling browser.close()
})();

Success looks like Example Domain printed in your terminal, with the page loaded in the Chrome window you started.

How do you end the script safely?

Let the script finish and leave Chrome running, then stop Chrome yourself by closing its window or ending the process you launched. That keeps cleanup in your hands.

None of these examples call browser.close() or any disconnect() method. Before you add browser.close() to a CDP-attached script, read the Browser.close entry in the Playwright API docs. The FAQ below quotes what it does to a connected browser.

Which endpoint and options does connect_over_cdp take?

The first argument to connect_over_cdp is an endpoint URL: either the HTTP address of the debugging port or the full WebSocket address of the browser. Everything else is a keyword option.

The HTTP form looks like http://localhost:9222. Playwright looks up the WebSocket address for you, so there’s nothing to copy. Use it when you picked the port yourself in the Chrome launch command and it stays the same.

The ws form looks like ws://localhost:9222/devtools/browser/<id>. The id changes per launch, so don’t hardcode it. Use this form when a provider hands you a complete URL.

Here is a short example with timeout and slow_mo:

browser = p.chromium.connect_over_cdp(
    "http://localhost:9222",
    timeout=10_000,  # milliseconds to wait for the connection
    slow_mo=100,     # pause 100 ms between actions so you can watch
)

A short timeout makes a wrong port fail fast instead of hanging. slow_mo helps while you debug a flow by eye.

For an endpoint behind a proxy or a token, add headers. Playwright sends them with the connection request:

browser = p.chromium.connect_over_cdp(
    "wss://cdp.example.com/devtools/browser/abc123",
    headers={"Authorization": "Bearer <token>"},
)

Newer Playwright releases add three more options. Check that your installed version lists them in the connect_over_cdp signature, and read the release notes for the version that introduced each one.

  • is_local=True tells Playwright the browser runs on the same machine as your script.
  • no_defaults=True is for a browser you did not start. Playwright leaves the existing default context settings alone, which matters for the logged-in profile covered below.
  • artifacts_dir="./artifacts" sets the folder where traces and downloads are saved for the attached browser.
browser = p.chromium.connect_over_cdp(
    "http://localhost:9222",
    is_local=True,
    no_defaults=True,
    artifacts_dir="./artifacts",
)

connect vs connect_over_cdp vs launch: which do you need?

Use connect_over_cdp to attach to a Chromium browser that’s already running, launch or launch_persistent_context when Playwright should start the browser, and connect when you run a Playwright server.

connectconnect_over_cdplaunchlaunch_persistent_context
Attaches to or startsAttaches to a Playwright serverAttaches to any Chromium-based browser exposing CDPStarts a clean browserStarts a browser on a user data dir
ProtocolPlaywright’s ownCDPPlaywright’s ownPlaywright’s own
Browser process ownerWhoever ran the serverWhoever started the browserPlaywrightPlaywright
State persistenceServer’s choiceWhatever the running browser holdsNone by defaultKept in the profile folder
Best fitRemote Playwright serverExisting logged-in browserClean CI runsPlaywright-managed profile

The Playwright docs say connect attaches to “an existing browser instance created via BrowserType.launchServer in Node.js”. It speaks Playwright’s own protocol end to end, which is why the connect_over_cdp docs steer you back to it.

The BrowserType docs warn that a CDP connection is “significantly lower fidelity than the Playwright protocol connection”. They don’t list what differs, so treat any list you read elsewhere as unofficial and test your own version.

How can you test fidelity yourself?

Run the same checks through both methods. Start Chrome on port 9222 first, as in the step-by-step section.

import time
from playwright.sync_api import sync_playwright

def checks(ctx):
    page, out = ctx.new_page(), {}
    def t(name, fn):
        try:
            fn(); out[name] = "pass"
        except Exception as e:
            out[name] = "FAIL " + type(e).__name__
    t("navigate", lambda: page.goto("https://example.com"))
    t("screenshot", lambda: page.screenshot(path="s.png"))
    def download():
        page.set_content('<a download="a.txt" href="data:text/plain,hi">d</a>')
        with page.expect_download(timeout=5000) as d:
            page.click("a")
        d.value.path()
    t("download", download)
    def route():
        page.route("**/mock", lambda r: r.fulfill(body="ok"))
        assert page.evaluate("fetch('https://example.com/mock').then(r => r.text())") == "ok"
    t("route", route)
    t("trace", lambda: (ctx.tracing.start(), ctx.tracing.stop(path="t.zip")))
    return out

with sync_playwright() as p:
    t0 = time.perf_counter()
    b = p.chromium.launch()
    print("launch seconds:", time.perf_counter() - t0)
    print("launch", checks(b.new_context(accept_downloads=True)))
    b.close()

    t0 = time.perf_counter()
    b = p.chromium.connect_over_cdp("http://localhost:9222")
    print("cdp seconds:", time.perf_counter() - t0)
    print("cdp", checks(b.contexts[0]))

Compare the two printed dictionaries. Which features fail depends on your Playwright and Chrome versions, so don’t assume a result before you run it.

The perf_counter lines time each connection step. Repeat them several times, because the first run includes Chrome startup on one side only. Launch time also depends on your machine, so trust your own numbers over anyone else’s.

Pick this when

  • Attach with connect_over_cdp when you need a running browser’s logins, extensions or open tabs.
  • Use launch for clean CI runs where nothing should carry over.
  • Use launch_persistent_context when Playwright can own the profile directory and you want state to survive between runs.
  • Use connect when you control a Playwright server and want full fidelity. For wider setups, see our browser automation use case.

How do you fix a refused, timed-out or empty CDP connection?

You fix a refused, timed-out or empty CDP connection by matching the symptom to its cause and running one check before you change anything. These five cover most failures.

  1. Connection refused. Nothing is listening on the port. Either Chrome isn’t running with --remote-debugging-port, or Chrome ignored the flag because you pointed it at your everyday profile folder. Check with curl http://127.0.0.1:9222/json/version. If it fails, quit Chrome and restart it with --remote-debugging-port=9222 --user-data-dir=/tmp/cdp-profile.

  2. Timeout. The port exists, but the handshake never finishes. The usual causes are a wrong host or port, a firewall, or a proxied endpoint that needs headers. Playwright’s connect_over_cdp docs say the timeout option “Defaults to 30000 (30 seconds)”. Confirm the endpoint with curl, pass headers if a proxy needs them, and raise timeout only after that.

  3. Port already in use. A second Chrome or another tool owns the port, so the new instance can’t bind it. Run lsof -i :9222 on macOS or Linux, or netstat -ano | findstr 9222 on Windows, to see the owning process. Quit it, or give the new Chrome another port such as 9223.

  4. Empty browser.contexts. The list is empty, so contexts[0] raises an error. Use the default context when it exists and create one only when the list is empty:

context = browser.contexts[0] if browser.contexts else browser.new_context()

A new context starts without the existing login, so don’t expect your cookies in it.

  1. No pages. context.pages is empty because the context has no open tab. Call context.new_page(), or wait for the tab you expect:
page = context.pages[0] if context.pages else context.new_page()

If a script opens the tab itself, use context.wait_for_event("page") instead of polling.

Change one thing at a time and re-run the curl check after each change. If /json/version answers and Playwright still fails, the problem is in your connect call, not in Chrome.

Is it safe to expose a Chrome remote debugging port?

Exposing a Chrome remote debugging port is safe only when nothing but your own machine can reach it. The port has no password in the setup above, so anyone who connects can open pages, run scripts and read cookies from every logged-in tab in that profile. Treat it like handing over your unlocked laptop.

Apply these controls in this order:

  1. Bind to localhost. Keep Chrome on 127.0.0.1 and never on 0.0.0.0 or a public address.
  2. Publish Docker ports to loopback only. Use -p 127.0.0.1:9222:9222, not -p 9222:9222, which listens on every interface.
  3. Tunnel for remote hosts. Run ssh -N -L 9222:127.0.0.1:9222 user@host, then connect Playwright to http://127.0.0.1:9222 on your own machine. SSH handles the encryption and login.
  4. Never open the port in a firewall or router. If you need it from elsewhere, tunnel.

The Playwright headers option can send an auth token to a proxied or provider endpoint. It only proves who you are to that endpoint and doesn’t replace the network controls above.

Where the browser runs changes who holds the risk:

Local ChromeDocker containerRemote host + SSH tunnelHosted browser provider
Who controls the browserYouYouYouThe provider
Where the data livesYour diskA container volume you manageThe remote diskThe provider’s servers
LatencyLowestLowAdds a network round trip per commandDepends on region
MaintenanceChrome updatesImage and port rulesServer, SSH keys, updatesMostly the provider’s
Trust neededOnly yourselfOnly yourselfHost and key hygieneThe provider with your sessions

If the profile holds real logins, a hosted endpoint means your sessions leave your control. Pick that on purpose, not by default.

How do you attach Playwright to a logged-in profile safely?

You attach Playwright to a logged-in profile safely by giving automation its own Chrome profile and never pointing it at your everyday one. Recent Chrome won’t open the debugging port on the default data directory anyway, so a separate --user-data-dir is required.

  1. Create a dedicated profile. Start Chrome with --remote-debugging-port=9222 and --user-data-dir=/path/to/automation-profile. Log in by hand once, then use that folder for automation only. Keep the port on localhost.
  2. Pick the right page. Playwright’s BrowserType docs say “The default browser context is accessible via” browser.contexts, and its pages include tabs that were already open. Choose a page by URL, or open a new one.
  3. Reuse contexts[0]. A context from browser.new_context() on an attached browser starts empty, so your login lives in the default one. The no_defaults option from the endpoint section changes that behavior.
  4. Don’t treat storageState as a profile. Playwright documents it as holding cookies and a local storage snapshot (IndexedDB only on request). A saved state file can’t carry the profile’s extensions or its browser fingerprint.

For an AI agent, use a profile that holds no banking or personal logins. The open-source (MIT) wayfern npm package, described in the Wayfern docs, sits on top of Playwright and humanizes mouse, typing and scroll.

Managed-profile pattern. Donut Browser’s REST API docs describe a local REST API at 127.0.0.1:10108 (loopback only, bearer token, base path /v1). A POST /v1/profiles/{id}/run call returns remote_debugging_port.

Your script reads that value and calls chromium.connectOverCDP('http://127.0.0.1:' + port). Don’t hardcode 9222 here, because the port comes from the response.

The launch endpoint needs the Pro plan and returns HTTP 402 without it. Donut’s quickstart confirms the free plan has no CDP automation endpoint. You can see what’s included on the Pro plan page.

Donut Browser is one option. The steps above work with any Chromium, and no browser guarantees an account will never be banned.

Frequently asked questions

How do I connect Playwright to an already running Chrome?

Start Chrome with --remote-debugging-port=9222 and a separate --user-data-dir, then call chromium.connect_over_cdp('http://127.0.0.1:9222'). Playwright’s BrowserType docs explain that Playwright attaches through the Chrome DevTools Protocol, so you reuse that browser’s open tabs and sessions. Open http://127.0.0.1:9222/json/version first to confirm the endpoint responds.

Does closing the Playwright browser close my Chrome?

The official docs only say what close() does to a connected browser, not whether Chrome survives. For a connected browser, Playwright’s Browser docs say it “clears all created contexts belonging to this browser and disconnects from the browser server.” They don’t say whether your Chrome process keeps running, so test it before you rely on it.

Why does Chrome ignore —remote-debugging-port?

Chrome 136 and later ignore the switch when it points at the default Chrome data directory. Add --user-data-dir=/tmp/chrome-cdp (any new folder) and Chrome treats it as a separate profile, so the port opens.

Can I use connect_over_cdp with Firefox or WebKit?

No, the docs say connecting over CDP is only supported for Chromium-based browsers. That covers Chrome, Edge and Brave, but not Firefox or WebKit. For those two, start the browser with launch() instead.

Is connect_over_cdp slower or less capable than launch()?

It can be less capable, and the docs don’t list which features differ or publish timings. The comparison section quotes the BrowserType warning in full. Run its fidelity test, with the same script under both methods, and compare the results on your own versions.

What should you do next?

Your next step is to run one example against a throwaway profile, then test your real script both ways.

Today (10 minutes): Start a throwaway Chrome with a new --user-data-dir, then run the Python or TypeScript example. If you get a page back, Playwright connect over CDP works on your machine.

This week: Run the fidelity test on your real script, first with launch() and then with connect_over_cdp(), and fix anything that behaves differently.

Before you share anything: Bind the port to 127.0.0.1 or use an SSH tunnel. Never leave it open to the network.

Everything above works with any Chromium browser. You don’t need Donut Browser for it.

If you’d rather have managed profiles that each launch with their own CDP port, see the Pro plan on the pricing page and the REST API quickstart. The CDP launch needs Pro; the free plan covers local profiles.

Donut Browser is open source (AGPL-3.0) and has zero telemetry, so you can read the code before you pay. I don’t sell proxies either.

Sources

Changes to remote debugging switches to improve security |… · BrowserType | Playwright Python · BrowserContext | Playwright · BrowserType | Playwright · docs.docker.com · Wayfern · Playwright · Docs — Donut Browser · REST API — Donut Browser Docs · Quickstart — Donut Browser Docs. Every other source is linked where the article cites it.