---
title: "Playwright Connect Over CDP: Attach to a Real Profile"
description: "Playwright connect over CDP: launch Chrome with --remote-debugging-port and a separate user data dir, then call connect_over_cdp. Reuse logins safely."
url: "https://donutbrowser.com/vi/playwright-connect-over-cdp/"
---

1. [Trang chủ](https://donutbrowser.com/vi/)

2. [Blog](https://donutbrowser.com/vi/blog)

3. Playwright Connect Over CDP: Attach to a Real Profile

# Playwright Connect Over CDP: Attach to a Real Profile

October 14, 2026

playwright connect-over-cdp chrome

**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](https://developer.chrome.com/blog/remote-debugging-port) 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](https://playwright.dev/python/docs/api/class-browsertype), `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](https://playwright.dev/docs/api/class-browsercontext), `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](https://playwright.dev/docs/api/class-browsertype) 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](https://api.birchseek.com/m/QIw_wpYbNW-8IufTkBXX8e_ormwBRk0P.webp)

[Playwright’s BrowserType docs](https://playwright.dev/python/docs/api/class-browsertype) 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.

|  | `connect` | `connect_over_cdp` | `launch` | `launch_persistent_context` |
| --- | --- | --- | --- | --- |
| Attaches to or starts | Attaches to a Playwright server | Attaches to any Chromium-based browser exposing CDP | Starts a clean browser | Starts a browser on a user data dir |
| Protocol | Playwright’s own | CDP | Playwright’s own | Playwright’s own |
| Browser process owner | Whoever ran the server | Whoever started the browser | Playwright | Playwright |
| State persistence | Server’s choice | Whatever the running browser holds | None by default | Kept in the profile folder |
| Best fit | Remote Playwright server | Existing logged-in browser | Clean CI runs | Playwright-managed profile |

[The Playwright docs](https://playwright.dev/docs/api/class-browsertype) 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](https://playwright.dev/python/docs/api/class-browsertype) 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](https://donutbrowser.com/use-cases/automation).

## 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](https://playwright.dev/python/docs/api/class-browsertype) 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 Chrome | Docker container | Remote host + SSH tunnel | Hosted browser provider |
| --- | --- | --- | --- | --- |
| **Who controls the browser** | You | You | You | The provider |
| **Where the data lives** | Your disk | A container volume you manage | The remote disk | The provider’s servers |
| **Latency** | Lowest | Low | Adds a network round trip per command | Depends on region |
| **Maintenance** | Chrome updates | Image and port rules | Server, SSH keys, updates | Mostly the provider’s |
| **Trust needed** | Only yourself | Only yourself | Host and key hygiene | The 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](https://playwright.dev/python/docs/api/class-browsertype) 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](https://donutbrowser.com/browser-fingerprinting/).

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](https://donutbrowser.com/docs/wayfern/), sits on top of Playwright and humanizes mouse, typing and scroll.

**Managed-profile pattern.** [Donut Browser’s REST API docs](https://donutbrowser.com/docs/rest-api/) 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](https://donutbrowser.com/docs/quickstart/) confirms the free plan has no CDP automation endpoint. You can see what’s included on the [Pro plan page](https://donutbrowser.com/pro/).

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](https://playwright.dev/python/docs/api/class-browsertype) 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](https://playwright.dev/python/docs/api/class-browser) 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](https://developer.chrome.com/blog/remote-debugging-port).** 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](https://donutbrowser.com/pricing) and the REST API quickstart. The CDP launch needs Pro; the free plan covers local profiles.

[Donut Browser](https://donutbrowser.com) 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 |…](https://developer.chrome.com/blog/remote-debugging-port) · [BrowserType | Playwright Python](https://playwright.dev/python/docs/api/class-browsertype) · [BrowserContext | Playwright](https://playwright.dev/docs/api/class-browsercontext) · [BrowserType | Playwright](https://playwright.dev/docs/api/class-browsertype) · [docs.docker.com](https://docs.docker.com/engine/network/port-publishing/) · [Wayfern · Playwright · Docs — Donut Browser](https://donutbrowser.com/docs/wayfern/) · [REST API — Donut Browser Docs](https://donutbrowser.com/docs/rest-api/) · [Quickstart — Donut Browser Docs](https://donutbrowser.com/docs/quickstart/). Every other source is linked where the article cites it.

## On this page

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

- How do you end the script safely?

- Which endpoint and options does connect\_over\_cdp take?

- connect vs connect\_over\_cdp vs launch: which do you need?

- How can you test fidelity yourself?

- Pick this when

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

- Is it safe to expose a Chrome remote debugging port?

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

- Frequently asked questions

- How do I connect Playwright to an already running Chrome?

- Does closing the Playwright browser close my Chrome?

- Why does Chrome ignore —remote-debugging-port?

- Can I use connect\_over\_cdp with Firefox or WebKit?

- Is connect\_over\_cdp slower or less capable than launch()?

- What should you do next?

- Sources
