PRACTICAL GUIDE / Playwright browser bind multi client isolation
Share one browser without sharing one user's state
Connect several Playwright clients to one bound browser, isolate each client with its own context, and close sessions without leaking state or control.
In this guide7 sections
- Separate the browser server from the user session
- Bind once and create one context per client
- Put endpoint and context cleanup in fixtures
- Work a two-role scenario all the way through
- Tell contamination from a dead connection
- Roll out a shared browser without hiding the risk
- Do not bind when sharing the process adds no value
What you will learn
- Separate the browser server from the user session
- Bind once and create one context per client
- Put endpoint and context cleanup in fixtures
- Work a two-role scenario all the way through
Two automation clients attach to the same browser, and the second client lands in the first user's account. The endpoint worked, every command returned, and the shared session still failed its most important requirement. Connectivity and isolation are different contracts.
Playwright 1.59 added browser.bind() so a launched browser can accept connections from playwright-cli, Playwright MCP, and other Playwright clients. Multiple clients are supported. Nothing in that promise says those clients should operate in one browser context, share one page, or trust one another.
Separate the browser server from the user session
The process that calls chromium.launch() owns the browser. Calling browser.bind(title, options) publishes that already launched browser and returns an endpoint. A connecting process passes the endpoint to chromium.connect(). The connection yields another Browser object that speaks the Playwright protocol to the owner process.
The Browser API documents two endpoint shapes. If neither host nor port is supplied, Playwright binds through a named pipe. Supplying either option creates a WebSocket server; port: 0 lets the operating system choose an available port. The title identifies the server. workspaceDir associates a working directory, and metadata associates descriptive values.
None of those options creates an authorization policy. A title is not a password. Metadata does not grant roles. A workspace directory does not confine browser navigation or filesystem access by some external security boundary. Keep the endpoint on a trusted local boundary, and decide separately which processes are allowed to receive it.
A browser context is the session boundary. Playwright describes contexts as isolated, incognito-like profiles. Cookies, local storage, and other browser state live inside the context. Pages created in one context intentionally share that context's state. Pages in different contexts are the ordinary way to model separate users inside one browser process.
That yields a simple ownership chain:
| Resource | Created by | Safe meaning | Required cleanup |
|---|---|---|---|
| Launched browser | Owner process | Shared execution process | Owner closes it after every client finishes |
| Binding endpoint | Owner process | Connection path into that browser | Owner calls unbind() after clients finish and close |
| Client connection | Each consumer | One Playwright protocol connection | Consumer closes its connected Browser object |
| Browser context | Client or actor fixture | One isolated browser-side identity | Creator closes the context |
| Page | Actor context | A tab for that identity | Context closure closes its pages |
Confusing any two rows causes hard-to-explain failures. A new connection is not automatically a new user. A new page in an existing context is not a new user. Closing a page is not a storage reset. Calling unbind() is a connection cutoff, not a drain signal.
Version compatibility belongs in setup. browserType.connect() requires the connecting Playwright client to match the server's major and minor version. Lock the owner and consumers to the same workspace dependency where possible. If connection setup fails before any context exists, compare npx playwright --version on both sides before investigating cookies or application state.
Bind once and create one context per client
A multi-client mechanism test should create two real connections and then prove that the harness gives each connection a different context. The assertion must inspect state that would leak if a future refactor cached and returned one context. Cookie state is a direct, browser-level signal and does not need a live application server.
import { test, expect, chromium, type Browser, type BrowserContext } from '@playwright/test';
async function createClientSession(
remoteBrowser: Browser,
user: string,
): Promise<BrowserContext> {
const context = await remoteBrowser.newContext();
await context.addCookies([{
name: 'qa-user',
value: user,
url: 'https://example.test',
}]);
return context;
}
test('connected clients receive isolated browser contexts', async () => {
const owner = await chromium.launch();
const { endpoint } = await owner.bind('isolation-check', {
host: '127.0.0.1',
port: 0,
});
const clientA = await chromium.connect(endpoint);
const clientB = await chromium.connect(endpoint);
const contextA = await createClientSession(clientA, 'alice');
const contextB = await createClientSession(clientB, 'bob');
try {
const cookiesA = await contextA.cookies('https://example.test');
const cookiesB = await contextB.cookies('https://example.test');
expect(cookiesA.find(cookie => cookie.name === 'qa-user')?.value).toBe('alice');
expect(cookiesB.find(cookie => cookie.name === 'qa-user')?.value).toBe('bob');
expect(contextA).not.toBe(contextB);
} finally {
await contextA.close();
await contextB.close();
await clientA.close();
await clientB.close();
await owner.unbind();
await owner.close();
}
});This oracle can fail in a meaningful way. If createClientSession() is changed to return a module-level cached context, Bob's cookie can overwrite Alice's cookie and the identity assertion fails. If a caller accidentally passes one remote Browser and one cached context to both actors, the object and cookie checks expose it. The test does not assert a value against itself.
In an application test, prefer a server-visible identity marker as the final assertion. After Alice and Bob sign in through their own contexts, each page should display the expected account identifier. Then perform a cross-user rejection check: an order created by Alice should not appear in Bob's account. Cookie separation proves browser isolation; the order assertion proves the product's authorization and test-data isolation.
Do not put both actors in two pages created from one context. That is the right model for two tabs owned by one user, because cookies and local storage should be shared. It is the wrong model for two independent customers. A code review that sees const pageA = await context.newPage() beside const pageB = await context.newPage() should ask whether the scenario represents tabs or identities.
A helper named newClientPage() can hide this mistake. Look at what it creates. If it accepts a BrowserContext, it cannot promise a new identity unless the context itself is fresh. A safer role factory accepts a Browser connection and returns both the new context and its page, making cleanup ownership visible to the caller.
Contexts do not isolate every possible resource. Two users can still hit the same backend account, email inbox, tenant, queue, object-storage key, or payment fixture. Use distinct server-side identifiers tied to the test run. If browser cookies differ while both pages show changes to the same cart, the binding architecture is probably fine and the test-data architecture is not.
Put endpoint and context cleanup in fixtures
Ad hoc try blocks are useful for a mechanism test, but a suite needs lifecycle ownership. A worker-scoped owner can launch and bind one browser. A test-scoped fixture can connect a client, create a fresh context, start tracing for that manually created context, and close those resources after the test.
The distinction around tracing matters. Playwright Test automatically manages artifacts for its built-in context fixture. A context created directly through a connected Browser is yours. If a trace from that context is required for triage, start and stop it explicitly instead of assuming the standard use.trace setting owns it.
import {
test as base,
chromium,
type Browser,
type BrowserContext,
} from '@playwright/test';
type BoundBrowser = { owner: Browser; endpoint: string };
export const test = base.extend<
{ remoteContext: BrowserContext },
{ boundBrowser: BoundBrowser }
>({
boundBrowser: [async ({}, use) => {
const owner = await chromium.launch();
const { endpoint } = await owner.bind('qa-worker', {
host: '127.0.0.1',
port: 0,
workspaceDir: process.cwd(),
});
await use({ owner, endpoint });
await owner.unbind();
await owner.close();
}, { scope: 'worker' }],
remoteContext: async ({ boundBrowser }, use, testInfo) => {
const client = await chromium.connect(boundBrowser.endpoint);
const context = await client.newContext();
await context.tracing.start({ screenshots: true, snapshots: true, sources: true });
await use(context);
await context.tracing.stop({ path: testInfo.outputPath('remote-trace.zip') });
await context.close();
await client.close();
},
});
export { expect } from '@playwright/test';Test fixtures close their contexts and client connections first through Playwright's dependency ordering. Only after those consumers finish does the worker fixture call unbind(), which closes the remaining binding transport before the owner browser exits. In Playwright 1.61.1, unbind() is not a stop-new-only admission gate. Calling it while clients remain connected disconnects them, so context, page, and trace cleanup belongs before that call.
If trace collection itself fails, teardown should still attempt context and client closure. A production fixture can wrap trace stopping in try and place resource closure in finally, while preserving both errors through the runner's fixture reporting. Avoid a blanket catch that logs and discards the artifact failure. Missing evidence is relevant when the test is red.
A worker-scoped browser reduces process startup, but it widens the blast radius of an owner crash. One browser termination disconnects every client in that worker. That is a known cost of sharing the process. Tests that intentionally crash the browser or modify browser-process-wide settings should use a dedicated owner rather than the shared fixture.
Worker scope also means a binding title can repeat in separate workers. The WebSocket example avoids a fixed port by using 0, so operating-system allocation keeps endpoints distinct. If you use named pipes for CLI discovery, incorporate a stable worker identity in the title and record it in test output. Do not derive security from obscurity of the name.
Work a two-role scenario all the way through
Consider a customer who submits a refund request while a support agent reviews it. This is a good multi-client case because the two actors act concurrently, need different permissions, and may benefit from a second tool observing one shared browser process. It is also a case where a weak harness can pass while exercising one account twice.
Allocate backend identities before opening pages. The customer and agent need different users, and preferably different role-specific credentials created for the current run. Give the refund a unique business key that both actors can use in assertions. Browser contexts isolate cookies; the unique users and refund key isolate the server records. Both halves are required.
Authenticate through each actor's context and assert an identity marker immediately afterward. A successful redirect to /dashboard is not enough because both roles may use that route. Assert the visible email, role label, tenant name, or another stable marker returned by the application. That early assertion identifies a session mix-up before the refund steps make the resulting data harder to interpret.
import { expect, type Browser, type BrowserContext, type Page } from '@playwright/test';
type Credentials = { email: string; password: string; role: 'customer' | 'support' };
type Actor = { context: BrowserContext; page: Page; credentials: Credentials };
async function openActor(
browser: Browser,
baseURL: string,
credentials: Credentials,
): Promise<Actor> {
const context = await browser.newContext({ baseURL });
const page = await context.newPage();
await page.goto('/login');
await page.getByLabel('Email').fill(credentials.email);
await page.getByLabel('Password').fill(credentials.password);
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByTestId('signed-in-email')).toHaveText(credentials.email);
await expect(page.getByTestId('signed-in-role')).toHaveText(credentials.role);
return { context, page, credentials };
}This helper takes a Browser, not a BrowserContext, so it cannot silently create both roles inside a caller's shared context. It returns the context to make cleanup explicit. It also checks identity before returning, which keeps a bad storage-state file or server-side account collision from masquerading as a later authorization defect.
The customer action should produce a value the support agent can locate, such as the refund reference shown in a confirmation. Read that value from the customer page and search for it from the agent page. Then add the rejection side: the customer must not see support-only controls, and a second customer must not open the first customer's refund by guessing its reference. A two-role workflow that checks only the happy handoff does not test isolation.
Keep locators and page objects actor-specific. A module-level currentPage variable is an easy way for the agent's login to replace the customer's page. Constructors should accept a Page and retain that instance. Name variables by role throughout the test. When a failure screenshot says "customer" but contains the support navigation, the attachment name and identity assertion should expose the mismatch immediately.
Context-scoped setup must follow the same rule. Register a route, permission, init script, or exposed binding on the context that owns the actor. Adding an API mock to the customer's context should not alter the support agent's response. If both actors unexpectedly receive the same mock, inspect whether a helper accepted the wrong context or whether the behavior comes from the shared backend rather than browser routing.
Downloads deserve ownership too. A support export should be saved through the support page's download event and attached under the support actor's name. Do not scan one global download directory and take the newest file; parallel clients can race and attach another actor's document. Use testInfo.outputPath() or another per-test path, and connect each attachment to the action that produced it.
When the workflow fails, classify it at the earliest broken actor boundary. Wrong identity immediately after login is authentication or context reuse. Correct identities followed by shared draft data is backend fixture collision or a product authorization issue. Correct data followed by the wrong screenshot is artifact naming. A disconnected client during the handoff is browser ownership. Those categories lead to different fixes even though the final agent assertion may say the refund was not found.
Close actors only after their pending work ends. If the customer action triggers an asynchronous request, wait for the product confirmation before closing that context. If the agent has an active download, await the download before teardown. Closing contexts as a form of synchronization can cancel the operation the test intends to prove, turning an application race into a lifecycle error.
This worked example costs more setup than two pages in one context. It creates two logins, two contexts, distinct server data, and actor-specific evidence. That cost buys a claim worth making: two separate users can collaborate without inheriting each other's browser state or records. If the product scenario only needs two tabs for one user, use one context and avoid the extra machinery.
Tell contamination from a dead connection
Shared state and lost connectivity can produce the same final symptom: the page no longer behaves as the actor expects. The earlier evidence is different.
For contamination, both Browser.isConnected() values remain true. Commands execute. The wrong cookie, storage value, account label, or page appears. Capture the client label, context creation point, current page URL, and a redacted identity marker. If the context for Bob contains Alice's session cookie, inspect context reuse. If the cookies are distinct but the UI shows one account, inspect authentication and backend test data.
Do not attach raw storage state to a broadly visible CI report. It can contain session cookies and origin storage that grant account access. A diagnostic helper can record cookie names, domains, paths, and a one-way digest of selected non-secret values while omitting the values themselves. The useful question is whether a cookie from the wrong identity exists in the context, not whether every reviewer can replay that identity.
Compare context state at two points: immediately after creation and immediately after login. Unexpected state in a brand-new context points to harness reuse or an explicit storage-state option. Clean creation followed by the wrong authenticated marker points to the login flow, credential allocation, or application session handling. That short chronology is more decisive than dumping storage only after several actors have edited the same record.
If a test installs cookies programmatically, log which actor owns the setup operation. A common bug seeds both contexts from one mutable credentials variable after a loop advances. The contexts remain technically isolated while both receive Alice's cookie. Assert the identity in each page after seeding so data preparation cannot create a false browser-isolation diagnosis.
For an owner shutdown, connected clients emit a disconnected event and isConnected() becomes false. In-flight operations fail because the browser process is gone. That is infrastructure or lifecycle evidence, not proof of cross-client state. Check who called owner.close(), whether a worker teardown ran early, and whether the browser crashed.
For binding shutdown, treat unbind() as a cutoff. In Playwright 1.61.1 it stops the server by closing the named-pipe or WebSocket transport, so clients that are already connected are disconnected too and their in-flight operations can fail. Calling it immediately after the expected clients connect terminates those active sessions. If the operational goal is to drain, stop distributing the endpoint or use an external admission gate that can refuse new handshakes, wait for every connected client to finish and close its connection, and only then call unbind(). Playwright does not provide drain-only behavior through this method in that version.
For client-local closure, calling clientA.close() on a connected Browser object ends that client's connection and nothing else. It does not close the contexts that client created. This is the single most surprising property of a bound browser, and in Playwright 1.61.1 it is deliberate rather than a bug. A browser published through bind() is served in shared mode, so the server-side browser dispatcher is constructed with context isolation turned off. Contexts created over that connection are never added to the dispatcher's isolated-context set, and the disconnect-time cleanup routine closes only that set. The set is empty, so cleanup closes nothing.
The result is directly observable. Connect client A to a bound browser, create one context, write a session cookie into it, then call clientA.close(). The owner still reports one live context, and that context still holds the cookie. Connect a fresh client B to the same endpoint and it enumerates that context and can read the cookie, because in shared mode the dispatcher forwards the browser's existing contexts to every connecting client. A later client can therefore inspect and drive an earlier client's authenticated session.
Contrast that with browserType.launchServer(), which is the connected-browser mode most readers have in mind. That mode leaves context isolation on, so each connection's contexts are tracked and closed when the connection drops, and a freshly connected client sees zero contexts. The two modes share the connect() call and the Browser type while differing on exactly the property that matters for multi-client isolation. Do not carry an intuition from one to the other.
Two things actually close a context created over a bound browser. The first is whoever created it calling context.close(), which is why the ownership table assigns that duty to the creator and why the fixtures in this article close the context before closing the client. The second is the owner process ending the browser through owner.close(), which takes every context with it and is a shutdown, not a per-client cleanup. There is no third path, and in particular no automatic per-connection reclaim.
Client closure must also not be used as the suite's signal to terminate the owner for everyone. Keep the variable names owner, clientA, and clientB; generic names such as browser1 and browser2 invite the wrong close call and make the missing context.close() harder to spot in review.
A version mismatch fails even earlier. No application page or user context exists. Save the owner and client Playwright versions beside the connection error. The protocol requirement is major-and-minor compatibility, so "both are Playwright" is insufficient. Package-manager ranges that resolve at different times on separate machines can violate it. Use one lockfile or publish the owner package with an exact client requirement.
Endpoint confusion is another near-miss. A stale endpoint can refer to a previous browser, or a hard-coded port can be occupied by an unrelated process. A successful WebSocket handshake alone does not identify the intended run. Include a run identifier in bind metadata for observability and expose a harmless page marker or owner-side record that clients can verify. Metadata helps diagnosis, but it remains descriptive rather than an authentication gate.
Finally, distinguish browser-context leakage from application cache behavior. Service workers, CDN responses, and backend caches may make two isolated users see the same stale content. Inspect cookies and context identities first. If those are clean, use response headers, account IDs, and server logs to trace the application layer. Recreating contexts repeatedly will not fix a shared backend cache key.
Roll out a shared browser without hiding the risk
Begin with one worker and two clients in a dedicated architecture spec. Make it prove isolated cookies, distinct authenticated accounts, cross-user data rejection, client-local cleanup, and owner shutdown. Introduce one controlled failure at a time during review. Reusing a context should fail the cookie test. Reusing an account should fail the order-visibility test. Closing the owner early should produce disconnected-client evidence.
Then run the same spec with multiple workers. Each worker should receive its own owner browser and operating-system-assigned port. This finds module-level endpoint variables, fixed ports, repeated profile directories, and teardown that assumes only one browser exists. Do not expand the whole suite until this layer remains understandable under concurrency.
name: Bound browser isolation
on:
pull_request:
jobs:
browser-bind:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
- name: Enable pnpm through Corepack
run: corepack enable
- name: Install the locked workspace
run: pnpm install --frozen-lockfile
- name: Install the matching Chromium build
run: pnpm exec playwright install --with-deps chromium
- name: Exercise two worker-owned bound browsers
run: pnpm exec playwright test tests/browser-bind-isolation.spec.ts --project=chromium --workers=2Keep the endpoint inside the job. Upload traces and redacted ownership records, not the live WebSocket URL. CI logs are often readable by more people and systems than the test process. An endpoint is a control channel while the owner is alive, so treat it like temporary sensitive connection data.
If clients must run on different hosts, place the endpoint behind a protected network boundary. host: '0.0.0.0' accepts network traffic reaching the machine; that convenience materially changes exposure. The documented bind options do not show an authentication credential. Use network isolation, a secured tunnel, short lifetime, and an explicit admission phase. To drain, stop publishing the endpoint or refuse new handshakes at an external gate, wait for connected clients to finish and close, then call unbind() and close the owner. Calling unbind() as soon as the expected clients connect would cut off their sessions.
Capacity is a concrete trade-off. Sharing a browser process saves launches, but contexts, pages, traces, video, and downloads still consume memory and disk. A noisy client can slow the process used by others. Limit concurrent contexts per owner based on observed jobs, not invented throughput figures. If one client's heavy trace makes unrelated tests time out, split owners by workload.
Failure containment is the other cost. One process crash affects every attached client. Separate browsers are more expensive but isolate crashes and browser-process settings. Use binding where shared observability or agent interoperability is valuable, not as a universal replacement for Playwright Test's default worker-managed browsers.
Do not bind when sharing the process adds no value
Ordinary end-to-end tests should keep the built-in browser, context, and page fixtures unless another client genuinely needs to attach. Those fixtures already provide context isolation and lifecycle management. Adding an endpoint, client protocol, and owner process increases complexity without improving a single-process suite.
Do not use one bound browser for mutually untrusted tenants. Browser contexts are a strong testing isolation primitive, but a bound endpoint grants broad automation control over the shared browser. Security boundaries between hostile users belong in separate processes, containers, hosts, and access-control systems designed for that purpose.
Avoid the pattern for tests that modify browser-process-wide settings or deliberately exercise crashes. Contexts cannot isolate every process-wide effect. A dedicated browser gives those tests honest ownership and prevents a destructive scenario from invalidating other clients' evidence.
Do not choose binding merely to reuse login state. Storage-state files or an authenticated setup project can seed separate contexts without sharing a live browser endpoint. If the requirement is two roles in one scenario, two contexts in the test's existing browser are simpler than two remote clients.
Skip a shared owner when client and server versions cannot be locked together. browserType.connect() has a version compatibility requirement. Independent tools that update on different schedules will create avoidable connection failures. Stabilize versions first or use an interoperability layer with a contract those tools can honor.
The pattern is worthwhile when an agent, CLI, debugger, or second Playwright process needs controlled access to a browser that another process launched. Even then, bind only the browser process. Give each logical user a fresh context, each test distinct backend data, each client explicit cleanup, and the owner a short, observable lifetime.
Be precise about what a fresh context buys you here. It separates cookies and storage between the actors that use it, so Alice's session does not bleed into Bob's page, and that is the reason to create one per logical user. It is not a security boundary between the connected clients, because a bound browser publishes its existing contexts to every client that attaches and never reclaims them when a client leaves. Any process holding the endpoint can enumerate and drive contexts it did not create. Treat per-user contexts as state hygiene inside a trusted set of clients, and treat control of the endpoint itself as the real access boundary. That is how multiple clients share infrastructure without turning shared state into a false pass.
// 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.
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.
- 01Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
- 02Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
- 03Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
- 04Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
FAQ / QUICK ANSWERS
Questions testers ask
Does browser.bind isolate connected Playwright clients?
No. Binding makes one launched browser available for other clients to connect to; it does not assign a private browser context to each caller. Create and close a separate BrowserContext for every client or logical actor that requires isolated cookies and storage.
What is returned by Playwright browser.bind?
The method returns an object containing an endpoint. Without host or port options the binding uses a named pipe, while specifying host or port creates a WebSocket endpoint that a matching Playwright client can pass to browserType.connect().
Does browser.unbind disconnect clients that are already attached?
Yes in Playwright 1.61.1. Unbind stops the bound server by closing its pipe or WebSocket transport, which disconnects attached clients and can fail their active operations. Let clients finish and close their connections before calling unbind.
Why can two isolated contexts still change the same account?
BrowserContext isolation separates browser-side state, not records in your backend. If both clients authenticate as one test user, they can still race on the same cart, profile, or order, so allocate distinct server-side identities too.
Should a bound browser listen on 0.0.0.0 in CI?
Prefer a loopback or named-pipe endpoint when clients run on the same machine. A network-reachable endpoint grants browser control to anything able to connect, and bind metadata or workspaceDir should not be treated as authentication.
RELATED GUIDES
Continue the learning route
GUIDE 01
Share a Bound Browser Between Playwright MCP Clients
A practical guide to Playwright browser bind MCP shared session, with implementation examples, debugging workflows, CI evidence, security controls, and release gates.
GUIDE 02
Model Multi-User Workflows with Isolated Playwright Browser Contexts
Model Playwright multi-user workflows with isolated browser contexts, actor-specific state, deterministic handoffs, parallel-safe data, and cleanup.
GUIDE 03
Playwright Java JUnit BrowserContext Isolation Architecture
Playwright Java junit browser context architecture: practical design, implementation, debugging, CI, metrics, and interview guidance for QA, SDET, and automation.
GUIDE 04
Multi-Role Authentication in Playwright with Separate storageState Files
Set up multi-role Playwright authentication with separate storageState files, isolated projects, safe credentials, and reliable permission checks.
GUIDE 05
Playwright Agentic Browser Automation and Evidence Guide
A practical guide to Playwright agentic browser automation evidence, with implementation examples, debugging workflows, CI evidence, security controls, and release gates.