PRACTICAL GUIDE / Playwright connectOverCDP isLocal option

Use isLocal only when Chrome really shares the host

Learn when Playwright and Chrome truly share a host, configure isLocal correctly, and diagnose file-path failures without masking a remote setup.

By The Testing AcademyUpdated August 7, 202619 min read
All field guides
In this guide6 sections
  1. Understand what the locality hint changes
  2. Connect to Chrome on the same host
  3. Keep remote and container setups honest
  4. Diagnose locality failures before changing timeouts
  5. Separate a locality defect from an artifact collection defect
  6. Roll the option out without destabilizing the suite
  7. Know when to leave it off

What you will learn

  • Understand what the locality hint changes
  • Connect to Chrome on the same host
  • Keep remote and container setups honest
  • Diagnose locality failures before changing timeouts

A download test works when Chrome is launched by Playwright, then becomes unreliable after the team attaches to an already running browser. The CDP endpoint is healthy, pages open, and locators still work. The confusing failures appear later, when an operation depends on where a file actually lives.

That is the situation the isLocal connection hint addresses. It is useful, but only after you describe the topology honestly. A loopback URL alone is not proof that the Playwright client and Chromium share a machine or a file system.

Understand what the locality hint changes

chromium.connectOverCDP() attaches a Playwright client to an existing Chromium-based browser through the Chrome DevTools Protocol. Unlike chromium.launch(), it does not start a browser process whose location Playwright already knows. The client receives an endpoint and must work with the browser that is listening there.

Playwright added the isLocal option in version 1.58. According to the public API contract, true tells Playwright that its process runs on the same host as the CDP server. Playwright may then use optimizations that rely on both sides seeing the same file system. That wording matters. The option is a topology hint, not a general performance switch.

Consider three layouts that all expose port 9222:

  1. A Node process and Chrome run as ordinary processes on the same Linux VM. They can both read /tmp/e2e-artifacts. This is local.
  2. Node runs in one Docker container and Chrome runs in another. A Compose network makes http://chrome:9222 reachable, but /tmp/e2e-artifacts refers to different container layers unless both containers mount the same volume. This is not local in the sense required by the option.
  3. An SSH tunnel maps a remote Chrome port to 127.0.0.1:9222 on the CI runner. The URL looks local, while the browser process and its files remain on another machine. This is remote.

The hint does not change which browser engines CDP supports. connectOverCDP remains a Chromium-only connection path. It does not upgrade the fidelity of the connection to match Playwright's own protocol, create isolated state, copy files between machines, or make a remote browser trust local paths. It also does not launch Chrome with a debugging port. Your infrastructure still owns browser startup, endpoint security, and shutdown.

The attached browser exposes its default context through browser.contexts(). That context may contain tabs, cookies, permissions, downloads, and service workers created before the test connected. The isLocal value does not clean any of them. If a test assumes a fresh context because the connection succeeded, it has mixed up process locality with test isolation.

This distinction explains why a basic smoke test can pass under a wrong setting. Navigation, DOM inspection, and most locator actions travel over CDP. They do not prove that a path-oriented optimization is valid. A team can therefore run hundreds of green assertions before one download, trace, or artifact workflow exposes the topology mistake.

Treat the flag as an assertion made by your deployment architecture. Before setting it, answer two questions separately:

  • Does the Playwright process connect directly to the Chromium process on the same host?
  • When either process refers to an absolute path, does that path identify the same storage from both sides?

If either answer is uncertain, leave the option unset or pass false. The default avoids claiming a property the client cannot verify for you.

Connect to Chrome on the same host

The cleanest use case is a workstation, VM, or CI runner where Chrome and the Playwright test process are peers. Start Chrome with a dedicated automation profile. Do not point remote debugging at a developer's normal profile, because current Chrome policy and Playwright guidance require a separate user data directory for this workflow.

On macOS, a local session can be started like this:

Shell
mkdir -p /tmp/tta-cdp-profile

"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
  --remote-debugging-port=9222 \
  --user-data-dir=/tmp/tta-cdp-profile \
  about:blank

The equivalent Chrome executable path differs on Linux and Windows, but the two important arguments stay the same. Use a dedicated profile and bind the debugging service only where your test process needs it. A CDP endpoint controls the browser, including authenticated pages, so it should never be exposed as a public service.

Verify the listener before involving Playwright:

Shell
curl --fail --silent http://127.0.0.1:9222/json/version

A healthy endpoint returns JSON containing values similar to these:

JSON
{
  "Browser": "Chrome/145.0.7632.6",
  "Protocol-Version": "1.3",
  "webSocketDebuggerUrl": "ws://127.0.0.1:9222/devtools/browser/4c6f..."
}

The exact browser version and identifier will differ. This response proves that the process running curl can reach Chrome. Because both commands run on the same host in this example and share /tmp, the locality claim is also defensible.

The following Playwright Test file attaches to that browser, inspects the existing default context, creates a page when necessary, and verifies a real browser response. It requires Playwright 1.58 or newer.

TypeScript
import { chromium, expect, test } from '@playwright/test';

test('uses the default context of a local CDP browser', async () => {
  const browser = await chromium.connectOverCDP(
    'http://127.0.0.1:9222',
    {
      isLocal: true,
      timeout: 10_000,
    },
  );

  try {
    const [context] = browser.contexts();
    expect(context, 'CDP browser should expose its default context').toBeTruthy();

    const page = context.pages()[0] ?? await context.newPage();
    const response = await page.goto('https://example.com/');

    expect(response?.ok()).toBe(true);
    await expect(page).toHaveTitle('Example Domain');
  } finally {
    await browser.close();
  }
});

Run only this file with one worker while proving the connection:

Shell
npx playwright test tests/cdp-local.spec.ts --workers=1

One worker is not a permanent requirement. It is a diagnostic choice. Several workers attaching to the same default context can race over pages, cookies, focus, and downloads. First prove one connection with one owner. Add concurrency only after each worker gets a separately managed browser or an isolation strategy that the application can tolerate.

There is another ownership detail in the finally block. For a connected browser, browser.close() disposes the Playwright client, clears contexts that client created, and disconnects. It is not the same lifecycle as closing a browser process launched by the test. The attached default context cannot be closed through context.close(), so avoid closing pre-existing pages merely to imitate fixture cleanup. Infrastructure that started the long-lived browser should remain responsible for terminating its process.

For a same-host artifact directory on Playwright 1.61 or newer, the public API also accepts artifactsDir. Create the directory from the Node side and pass its absolute path. Both processes must resolve it to the same place.

TypeScript
import path from 'node:path';
import { mkdir } from 'node:fs/promises';
import { chromium } from '@playwright/test';

const artifactsDir = path.resolve('test-results/cdp-artifacts');
await mkdir(artifactsDir, { recursive: true });

const browser = await chromium.connectOverCDP(
  'http://127.0.0.1:9222',
  {
    isLocal: true,
    artifactsDir,
  },
);

console.log({ contexts: browser.contexts().length, artifactsDir });
await browser.close();

Do not copy that second option into a repository pinned below 1.61. The connection API is versioned at the client, and TypeScript should be allowed to catch an option that the installed package does not support.

Keep remote and container setups honest

Most mistakes happen when a team equates network proximity with process locality. Containers make that assumption especially tempting. A Playwright container can reach a Chrome container through a private bridge in less than a millisecond, yet their root file systems are separate. Fast is not the same as local.

Make the deployment decision explicit instead of deriving it from the endpoint string:

TypeScript
import { chromium } from '@playwright/test';

type CdpTopology = 'same-host' | 'remote';

function readTopology(): CdpTopology {
  const value = process.env.CDP_TOPOLOGY;
  if (value === 'same-host' || value === 'remote') return value;
  throw new Error('Set CDP_TOPOLOGY to same-host or remote');
}

const endpoint = process.env.CDP_ENDPOINT;
if (!endpoint) throw new Error('Set CDP_ENDPOINT to the Chromium CDP URL');

const topology = readTopology();
const browser = await chromium.connectOverCDP(endpoint, {
  isLocal: topology === 'same-host',
  timeout: 15_000,
});

try {
  const [context] = browser.contexts();
  if (!context) throw new Error('Connected browser has no default context');

  const page = context.pages()[0] ?? await context.newPage();
  console.log({ topology, endpoint, pageCount: context.pages().length });
  await page.goto('https://example.com/');
} finally {
  await browser.close();
}

This wrapper looks almost too simple, which is its strength. CI declares CDP_TOPOLOGY=remote for a browser grid, SSH tunnel, Kubernetes service, or sidecar without shared storage. A single-host runner declares same-host. Reviewers can see the claim in pipeline configuration instead of reverse-engineering it from a hostname.

Suppose Kubernetes schedules Playwright and Chrome in two containers within one pod. They share a network namespace, so Chrome can listen on 127.0.0.1:9222. They do not automatically share every path. A deliberately mounted volume such as /artifacts can be common while /tmp remains private to each container. The public option is still a host-level hint, not a per-directory mount map. If only one directory is shared, do not generalize that fact into isLocal: true unless the Playwright behavior you rely on matches the supported topology and has been proven against your exact version.

Remote browser vendors add another layer. Their WebSocket or HTTP endpoint may terminate at a gateway before reaching a browser worker. Even if the gateway happens to run on your node, the browser does not share your file system. Leave the hint false. Use public download APIs such as download.saveAs() to place a received download where the test runner needs it, and use the provider's documented artifact channel for browser-side recordings.

The following download pattern does not assume that the browser's suggested path is a stable cross-machine path:

TypeScript
import path from 'node:path';
import { chromium, expect, test } from '@playwright/test';

test('saves a download through the Playwright client', async () => {
  const endpoint = process.env.CDP_ENDPOINT;
  if (!endpoint) throw new Error('CDP_ENDPOINT is required');

  const browser = await chromium.connectOverCDP(endpoint, {
    isLocal: false,
  });

  try {
    const [context] = browser.contexts();
    if (!context) throw new Error('Default browser context was not found');

    const page = context.pages()[0] ?? await context.newPage();
    await page.goto('https://example.test/reports');

    const downloadPromise = page.waitForEvent('download');
    await page.getByRole('button', { name: 'Export CSV' }).click();
    const download = await downloadPromise;

    expect(download.suggestedFilename()).toMatch(/\.csv$/);
    await download.saveAs(
      path.join(test.info().outputDir, download.suggestedFilename()),
    );
  } finally {
    await browser.close();
  }
});

The example application URL and button must exist in your system under test, but every Playwright symbol and signature shown is public. The important design choice is saveAs(): the test asks Playwright to materialize the download at a runner-owned destination instead of treating an internal browser path as a portable contract.

Diagnose locality failures before changing timeouts

A connection timeout and a locality mistake happen at different stages. If connectOverCDP cannot reach the endpoint, no browser object exists. Increasing an action timeout or changing isLocal cannot repair a closed port, a bad tunnel, or an authentication failure.

A refused local listener commonly surfaces with an error shaped like this:

Example
browserType.connectOverCDP: connect ECONNREFUSED 127.0.0.1:9222

Start with the endpoint from the same namespace as the test runner:

Shell
curl --verbose --max-time 5 http://127.0.0.1:9222/json/version

Inside Docker or Kubernetes, execute that check inside the Playwright container. A successful request from the host laptop says nothing about the container's loopback interface. If the endpoint requires headers, test those through an approved secret mechanism and pass the same header names in the documented headers connection option. Never paste a live browser-service token into a report.

If the connection succeeds but TypeScript underlines isLocal, inspect the client version:

Shell
npx playwright --version

With a client older than 1.58, the compiler may report that the object literal contains an unknown isLocal property. That is a version mismatch, not evidence that Chrome rejected the setting. Upgrade @playwright/test and any separately installed playwright packages together. Do not silence the error with as any; doing so turns a useful version gate into a runtime gamble.

Once pages open, capture a small topology record in CI:

Example
CDP endpoint host: chrome.internal
Declared topology: remote
Runner hostname: pw-runner-17
Shared artifact root: none
Contexts after connect: 1
Pages before test: 2

Those five lines settle more questions than a screenshot. They show that the client connected, that the job did not claim shared storage, and that the browser already contained state. Do not log the complete endpoint if it embeds credentials. Redact query strings and authorization headers.

For a path-related failure, inspect both sides. On the runner, print the absolute output directory and confirm it is writable. In the browser deployment, confirm whether the same absolute path is mounted and whether the browser user can write there. Matching strings are not enough. /artifacts/run-42 in two containers may refer to unrelated directories.

Shared storage also needs compatible identity and permissions. Two processes may mount the same volume while running as users with different numeric IDs. The Playwright process can create a directory that Chrome cannot write, or Chrome can create an artifact the report step cannot read. Record the directory owner, mode, and mount source on both sides instead of reporting only “path exists.”

Symlinks can create a subtler mismatch. /artifacts/current may resolve to one run directory for Node and another inside a container or chroot. Resolve the canonical path in the runner and compare it with deployment configuration for the browser process. Do not follow or rewrite arbitrary symlinks in a diagnostic test, especially when the endpoint belongs to another team.

Network file systems deserve a performance check even when they satisfy shared-path semantics. A mounted NFS or cloud volume makes the path common, but metadata and flush latency can erase the optimization you expected from locality. Measure the specific artifact operation and verify file completeness before treating “same mount” as success. The flag describes topology; it does not guarantee fast or durable storage.

Use DEBUG=pw:api for a short reproduction when you need Playwright API call chronology:

Shell
DEBUG=pw:api npx playwright test tests/cdp-local.spec.ts --workers=1

The useful breakpoint is the first operation after a successful connection that touches the disputed resource. If page.goto() and a title assertion pass but the download or artifact operation fails, the evidence points away from CDP reachability. If no default context appears, investigate how Chromium was launched and whether the endpoint is really a browser-level endpoint. Do not manufacture a context with an unchecked array access and then debug the resulting undefined error.

A trace can help with application actions, but it cannot prove two operating-system paths map to the same storage. Pair trace evidence with deployment evidence. The browser endpoint, process placement, volume mounts, user permissions, and client version belong in the incident record.

Separate a locality defect from an artifact collection defect

“The download is missing from the CI report” describes two boundaries that fail almost identically to a test author. The browser may have created bytes in storage the runner cannot see, which is a topology problem. Alternatively, the runner may have received and verified the file, but the post-test collector may be looking in a different directory or selecting a different set of files. Changing isLocal can affect the first boundary. It cannot repair the second.

Read the record in chronological order. First record the runner-owned absolute destination chosen for the artifact. Then record whether the operation completed, whether the runner could open the final file, and its observed size. Finally, compare that destination with the collector's configured root and the files the collector says it matched. A healthy run has one continuous chain: the completed operation names the run-scoped destination, the runner reads a nonempty file there, and the collector includes that same relative path. The values need to describe one attempt, not a file with the same name left by an earlier run.

The broken locality shape stops before runner verification. The browser action succeeds, but the runner cannot read the expected final path, and deployment evidence shows that the browser and runner resolve the path in different namespaces. The broken collection shape gets further: the runner reads the file successfully before teardown, yet the report contains nothing and the collector's matched set omits the path. At that point the file-system relationship used during the test has already been demonstrated. The CI artifact rule, collection timing, or retention boundary owns the missing report.

Several values are tempting but misleading. download.suggestedFilename() is a name proposed by the server or browser, not proof that bytes exist in the runner's output directory. A successful click or navigation proves even less. An empty report page does not prove the browser failed, because reporting happens later. Conversely, a file with the right name is not proof of success unless it belongs to the current test identity. Use a run-scoped destination and verify it before the attached browser is disconnected.

For an existing suite, land the runner-owned destination and current-attempt verification before enabling a locality canary. Next, make the collector include that known destination while isLocal retains its old value. Only then change the topology hint for the same-host job. This ordering gives the first regression a single boundary. If collection is changed at the same time as the connection option, a missing artifact cannot tell reviewers which half of the path moved.

The test owner owns the browser action, the final saveAs() destination when that path is used, and the in-test readability check. The browser infrastructure owner supplies process placement and mount evidence. The CI platform owner owns the collector root, collection start time, and matched-file record. A handoff should include the sanitized endpoint class, declared topology, runner and browser placement, absolute destination, test and retry identity, observed file size, disconnect time, collection time, and the collector's relative match. Do not include endpoint credentials or downloaded customer data.

The extra verification costs I/O. Reading or hashing a large artifact solely for topology proof can add noticeable time and disk traffic, so use a tiny controlled probe for the canary and keep the product's normal content assertion separate. A nonzero probe also does not catch corruption inside a real export. Locality and collection checks establish custody of the file, not that its contents satisfy the application contract.

Roll the option out without destabilizing the suite

Adding isLocal: true to every CDP call in one commit is a poor migration. CDP suites often contain several topologies hidden behind one helper: developer Chrome, a CI sidecar, a remote grid, and an emergency SSH tunnel. A global boolean silently mislabels at least one of them.

Start by finding connection ownership. There should be one helper or fixture that calls connectOverCDP, validates browser.contexts(), and records a sanitized endpoint class. If tests connect independently, consolidate them before adding the hint. This is less about style than incident response. One connection boundary gives you one place to enforce version and topology rules.

Introduce an explicit enum such as same-host and remote, not a loosely parsed boolean. Environment values like false, 0, and an empty string are routinely mishandled by JavaScript truthiness. Reject unknown values at startup so a misspelled CI variable cannot choose a branch accidentally.

Run the migration in four stages:

  1. Inventory each job that supplies a CDP endpoint. Record where Node runs, where Chromium runs, and whether their relevant storage is genuinely shared.
  2. Keep current behavior while logging the declared topology and Playwright client version. This exposes forgotten jobs without changing browser behavior.
  3. Enable isLocal only for a same-host canary job. Exercise navigation plus at least one file-oriented workflow used by the suite. Compare failures, artifact presence, and elapsed time with the control job.
  4. Expand by topology, not by repository. Remote jobs should remain explicit negative cases with isLocal: false or no property.

The canary needs a real assertion. A green connection test alone proves only network access. If your reason for the flag is an artifact workflow, assert that the artifact exists at the runner-owned path and has nonzero content. If your suite never uses a feature affected by shared file-system optimizations, you may see no measurable benefit. That is a valid result and a reason to avoid extra configuration.

Keep rollback cheap. The option should be controlled at the connection helper, and the previous false value should require one configuration change. Do not fork dozens of tests into local and remote copies. The product assertions should remain the same while connection setup varies.

Measure the cost you are trying to remove. Record connection time, relevant artifact transfer time, and end-to-end test duration separately. A faster total run after enabling the hint may come from a warm browser profile, fewer pages, or lower network latency. Without stage timings, the team can credit the wrong change and preserve a fragile configuration.

The rollout also needs an ownership rule for browser shutdown. Local canaries often start a dedicated Chrome process and may close it after the run. Shared development browsers and managed services have different owners. Encode that distinction beside topology, for example CDP_BROWSER_OWNER=test versus external, and let infrastructure perform cleanup when ownership is external.

Know when to leave it off

Do not enable the hint for an SSH-forwarded endpoint. Port forwarding moves bytes, not the browser's file system. The same rule applies to VPN routes, reverse proxies, and browser farms.

Do not use it to fix a slow connection. slowMo and connection timeout have separate meanings, and neither establishes locality. First identify whether the delay occurs during endpoint connection, page navigation, application response, download transfer, or artifact finalization.

Avoid it when the test process and browser sit in different containers without a fully understood shared-storage design. A shared pod, task, or VM label is not sufficient evidence. Container mount tables are evidence.

Leave it out when you do not own the browser topology. A third-party service can move sessions between workers without changing the endpoint you receive. Its documented integration should decide artifact handling.

Do not pair it with an assumption that the attached default context is clean. Locality does not isolate cookies, storage, tabs, permissions, background work, or downloads. If deterministic isolation is the real requirement, prefer a browser launched and context-managed by Playwright when the product allows it. The official API warns that CDP connections have lower fidelity than Playwright protocol connections.

Finally, do not upgrade solely to add this option unless the suite has a demonstrated same-host file workflow that benefits from it. A Playwright upgrade also changes bundled browser versions and can expose unrelated application differences. Pin the version, read the release notes, run the normal browser matrix, and make the topology change separately enough that a regression has one plausible cause.

The trade-off is straightforward. An honest isLocal: true can let Playwright use a more efficient same-host path. In return, your test configuration becomes coupled to deployment placement and shared-storage semantics. If the browser later moves behind a container boundary or remote service, that configuration must move with it. Teams that cannot maintain that contract should prefer the conservative default.

// FIELD DISPATCH

Get the QA Field Notes

Weekly QA battles, AI testing guides, and interview drills. Free on Substack.

// LIVE COURSE / THE TESTING ACADEMY

Playwright Automation Mastery

Go beyond Selenium. Master Playwright with JS/TS in 90 days.

From the instructor behind this guide.

Playwright jobs are growing 8x faster than Selenium. 90 days / 75+ live hrs / Tue-Thu-Sat 7 AM IST.

Code PROMODE / 10% offJoin the batch

The Testing Academy editorial desk

Practical QA guidance built around test evidence, production tradeoffs, and interview-ready explanations.

Published July 25, 2026 / Reviewed August 7, 2026

PRIMARY REFERENCES

Verify the details at the source

QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.

  1. 01
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

  2. 02
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

  3. 03
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

  4. 04
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

FAQ / QUICK ANSWERS

Questions testers ask

When should I set isLocal to true in connectOverCDP?

Set `isLocal: true` only when the Playwright process and the Chromium process can use the same host file system. A browser reached through SSH, Docker port forwarding, or a browser service is not local merely because its endpoint looks reachable.

Does isLocal make a remote Chrome browser behave like a local one?

No. The flag is a statement about topology, not a tunnel or file-transfer feature. It enables optimizations that depend on a shared file system, so a false value is safer than a false claim of locality.

How can I check whether my CDP endpoint is actually available?

Query the endpoint's `/json/version` URL from the same process environment that runs Playwright. A valid response proves CDP reachability, but you must still confirm that Chrome and the test process see the same paths before enabling `isLocal`.

Why does TypeScript reject the isLocal property?

Upgrade all Playwright packages together to version 1.58 or later, then regenerate the lockfile in your normal dependency workflow. A type error usually means the installed client predates the option, even if the remote browser itself is newer.

Should a CI test infer isLocal from a localhost URL?

Treat locality as explicit configuration instead. In containers and forwarded-port setups, `127.0.0.1` can name a proxy or sidecar while the browser owns a different file system.