PRACTICAL GUIDE / Playwright CDP session detached

Recover from a detached CDP session without hiding the cause

Classify Playwright CDP session closures, recreate only the dead scope, retain lifecycle evidence, and avoid retries that conceal browser crashes.

By The Testing AcademyUpdated August 7, 202625 min read
All field guides
In this guide6 sections
  1. Identify which layer actually ended
  2. Make session ownership explicit in code
  3. Replace an explicitly detached session, not the object
  4. Recover at the target that still exists
  5. Diagnose detachment from one lifecycle journal
  6. Read the journal as a transition, not a snapshot
  7. Separate timeout teardown from target loss
  8. Add recovery only where replay is safe
  9. Migrate an existing suite without changing its assertions
  10. Assign ownership before handing off the incident
  11. Know what this technique does not catch

What you will learn

  • Identify which layer actually ended
  • Make session ownership explicit in code
  • Replace an explicitly detached session, not the object
  • Recover at the target that still exists

A protocol command works for several steps, then the next session.send() rejects after the page closes a popup. Retrying the command against the same object changes nothing because that session is permanently closed. Before creating anything new, determine whether you lost one session, one target, or the entire browser connection.

Recovery is a lifecycle decision, not a catch-all retry. A replacement session can be correct when the page remains alive. It is wrong when the target vanished, and impossible when the browser transport disconnected.

Identify which layer actually ended

browserContext.newCDPSession(page) creates a DevTools Protocol session attached to a specific Chromium page or frame target. browser.newBrowserCDPSession() creates a browser-target session. Both return CDPSession, but they have different owners and survive different events.

The object supports three operations relevant to lifecycle work:

  • send(method, params?) sends one raw CDP command.
  • on(eventName, handler) subscribes to protocol events.
  • detach() closes that session's attachment to the target.

Playwright 1.59 added the documented close event. It fires when the session closes because its target ended or because code called detach(). The event confirms closure, but it does not supply a reason. Record nearby page and browser state to classify it.

There are four common boundaries:

Boundary lostSession closePage stateBrowser stateValid next action
Explicit session attachmentYesPage still openConnectedCreate a fresh session only if more CDP work is required
Page or popup targetYesThat page is closedConnectedUse the intended replacement page, then attach a new session
Frame target after process or document changeYes for that targetTop page may stay openConnectedResolve the current frame and attach after it reaches the required state
Browser transportAll sessions become unusablePages are no longer usableDisconnectedEnd the attempt; reconnect or restart at the browser boundary

Do not infer the row from the exception text alone. Several layers can surface a message containing "closed." Capture these values at the moment the session closes:

TypeScript
const browser = page.context().browser();
if (!browser) throw new Error('Expected a browser-owned context');

session.once('close', () => {
  console.log(JSON.stringify({
    event: 'cdp-session-close',
    pageClosed: page.isClosed(),
    contextClosed: page.context().isClosed(),
    browserConnected: browser.isConnected(),
    pageUrl: page.isClosed() ? null : page.url(),
  }));
});

browserContext.isClosed() is available in Playwright 1.59 and later. On an older supported version, keep explicit context-close evidence instead of inventing an equivalent method. The session close event itself also requires 1.59. Suites on earlier versions can observe page and browser closure, but upgrading gives the cleanest session signal.

CDP is Chromium-only. A test that calls newCDPSession() in a Firefox or WebKit project is a configuration error, not a detachment incident. Skip or exclude the entire CDP-specific case at collection time, and keep cross-browser product assertions outside the raw protocol helper.

Make session ownership explicit in code

The smallest safe rule is one owner, one target, one cleanup path. Create the session as late as practical, enable only the domains needed by that operation, and detach it in the same scope.

TypeScript
// tests/support/with-cdp-session.ts
import type { CDPSession, Page } from '@playwright/test';

export async function withPageCdp<T>(
  page: Page,
  operation: (session: CDPSession) => Promise<T>,
): Promise<T> {
  const session = await page.context().newCDPSession(page);
  let closed = false;
  session.once('close', () => {
    closed = true;
  });

  try {
    return await operation(session);
  } finally {
    if (!closed)
      await session.detach();
  }
}

Use it for a bounded read:

TypeScript
import { expect, test } from '@playwright/test';
import { withPageCdp } from './support/with-cdp-session';

test('reads the page execution context through CDP', async ({ page, browserName }) => {
  test.skip(browserName !== 'chromium', 'CDP is Chromium-only');
  await page.goto('https://example.test/dashboard');

  const value = await withPageCdp(page, async session => {
    await session.send('Runtime.enable');
    const result = await session.send('Runtime.evaluate', {
      expression: 'document.title',
      returnByValue: true,
    }) as { result: { value?: unknown } };
    return result.result.value;
  });

  expect(value).toBe('Dashboard');
});

The helper does not attempt recovery. Its job is ownership and cleanup. If the target closes inside operation, the close event prevents a second detach. The original send() error remains visible to the caller.

Do not place a session in a module-level variable or a worker fixture unless its target truly has that scope. The built-in page fixture is test-scoped. Holding its session across tests guarantees that a later case will use an attachment to a page that Playwright already closed during fixture teardown.

A broad helper that catches every send() error and silently creates another session is dangerous for two reasons. First, a new session starts with no enabled CDP domains or event subscriptions. Second, replaying a mutating command such as input dispatch, cache clearing, or script injection may duplicate work after the first command reached Chromium but its response was lost.

If several operations need one session, keep them in one test step or test-scoped fixture and expose a closed flag. Make callers ask for a fresh session through the owner rather than storing the raw object. This creates one place to re-enable domains and re-register listeners when replacement is genuinely allowed.

Network observation shows why setup must be replayed as one unit. A replacement session that sends Network.enable but forgets the listener is technically alive and functionally useless. Keep enablement and subscription beside each other:

TypeScript
import type { CDPSession } from '@playwright/test';

type RequestRecord = {
  requestId: string;
  method: string;
  url: string;
};

export async function startRequestJournal(session: CDPSession) {
  const requests: RequestRecord[] = [];

  session.on('Network.requestWillBeSent', event => {
    const payload = event as {
      requestId: string;
      request: { method: string; url: string };
    };
    requests.push({
      requestId: payload.requestId,
      method: payload.request.method,
      url: payload.request.url,
    });
  });

  await session.send('Network.enable');
  return requests;
}

Call this before the navigation or click that produces the requests. If the session closes afterward and policy allows a new observation window, call the whole function on the new session. Copying the old requests array is fine as retained evidence; assuming its listener moved to the new attachment is not.

Keep command intent with the owner as well. A helper called sendWithRetry(method, params) knows nothing about whether method is a read, mutation, subscription, or one-time trigger. A helper called readDocumentTitle(page) can safely define its own target, domain setup, command, expected return shape, and replacement policy. Narrow helpers make replay decisions reviewable.

Replace an explicitly detached session, not the object

The following test proves the recoverable case. It detaches the first session on purpose, confirms the close event, verifies that the closed object rejects further use, then creates a second session for the same live page.

TypeScript
// tests/cdp/explicit-detach.spec.ts
import { expect, test } from '@playwright/test';

test('creates a new attachment after explicit detach', async ({ page, browserName }, testInfo) => {
  test.skip(browserName !== 'chromium', 'CDP is Chromium-only');
  await page.goto('data:text/html,<title>CDP target</title><h1>Ready</h1>');

  const browser = page.context().browser();
  if (!browser) throw new Error('Expected a browser-owned context');

  const first = await page.context().newCDPSession(page);
  const firstClosed = new Promise<void>(resolve => first.once('close', () => resolve()));

  await first.send('Runtime.enable');
  const before = await first.send('Runtime.evaluate', {
    expression: 'document.title',
    returnByValue: true,
  }) as { result: { value?: string } };
  expect(before.result.value).toBe('CDP target');

  await first.detach();
  await firstClosed;

  await expect(first.send('Runtime.evaluate', {
    expression: '1 + 1',
    returnByValue: true,
  })).rejects.toThrow();

  const stateAfterDetach = {
    pageClosed: page.isClosed(),
    contextClosed: page.context().isClosed(),
    browserConnected: browser.isConnected(),
  };
  await testInfo.attach('state-after-detach.json', {
    body: JSON.stringify(stateAfterDetach, null, 2),
    contentType: 'application/json',
  });

  expect(stateAfterDetach).toEqual({
    pageClosed: false,
    contextClosed: false,
    browserConnected: true,
  });

  const second = await page.context().newCDPSession(page);
  try {
    await second.send('Runtime.enable');
    const after = await second.send('Runtime.evaluate', {
      expression: 'document.querySelector("h1")?.textContent',
      returnByValue: true,
    }) as { result: { value?: string } };
    expect(after.result.value).toBe('Ready');
  } finally {
    await second.detach();
  }
});

The state attachment is the justification for replacement. It proves the original target and browser still exist. The second session repeats Runtime.enable because protocol domain state belongs to the session, not the page.

Event listeners also belong to the old session. If the first attachment subscribed to Network.requestWillBeSent, the second does not inherit that subscription. Register listeners before the trigger they observe. Creating a replacement after a request already happened cannot reconstruct the missed event.

Double cleanup is a nearby bug. Calling detach() once in the operation and again in afterEach can produce Session already detached. Most likely the page has been closed. The wording mentions the page, but explicit earlier cleanup is another cause. Put detachment in one owner and treat that message as a lifecycle clue, not proof of a product crash.

Recover at the target that still exists

A page-target session should close when that page closes. The pending protocol command in this test rejects, the close event fires, and the browser remains connected:

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

test('classifies page closure while a CDP command is pending', async ({ page, browserName }, testInfo) => {
  test.skip(browserName !== 'chromium', 'CDP is Chromium-only');

  const browser = page.context().browser();
  if (!browser) throw new Error('Expected a browser-owned context');

  const session = await page.context().newCDPSession(page);
  const sessionClosed = new Promise<void>(resolve => session.once('close', () => resolve()));
  await session.send('Runtime.enable');

  const pending = session.send('Runtime.evaluate', {
    expression: 'new Promise(() => {})',
    awaitPromise: true,
  });

  await page.close({ reason: 'fixture target completed' });
  await sessionClosed;
  await expect(pending).rejects.toThrow();

  const state = {
    pageClosed: page.isClosed(),
    browserConnected: browser.isConnected(),
  };
  await testInfo.attach('target-close-state.json', {
    body: JSON.stringify(state, null, 2),
    contentType: 'application/json',
  });

  expect(state).toEqual({ pageClosed: true, browserConnected: true });
});

There is no valid reason to create another session for page; the target is gone. If the product opens a replacement tab, wait for that page as part of the user action and attach there:

TypeScript
const replacementPromise = page.context().waitForEvent('page');
await page.getByRole('button', { name: 'Open report' }).click();
const replacement = await replacementPromise;
await replacement.waitForLoadState('domcontentloaded');

const reportSession = await replacement.context().newCDPSession(replacement);
try {
  await reportSession.send('Runtime.enable');
  // Observe the report target here.
} finally {
  await reportSession.detach();
}

Do not choose a replacement with context.pages().at(-1) after the fact. Ads, authentication tabs, extensions, and unrelated popups can change ordering. Tie the page event wait to the action that is expected to create it and assert the new page's URL or visible identity before sending raw protocol commands.

Frames require the same discipline. newCDPSession() accepts a Frame, but a cross-origin frame can move between renderer targets as it navigates. Resolve the current frame after the expected URL is reached, attach to that frame, and treat a close during target replacement as the end of that observation window. A stale Frame or session reference cannot be repaired by waiting longer.

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

test('attaches after the payment frame reaches its final origin', async ({ page, browserName }) => {
  test.skip(browserName !== 'chromium', 'CDP is Chromium-only');
  await page.goto('https://shop.example.test/checkout');

  const paymentUi = page.frameLocator('iframe[name="payment"]');
  await expect(paymentUi.getByText('Card details')).toBeVisible();

  const paymentFrame = page.frame({ name: 'payment' });
  if (!paymentFrame) throw new Error('Payment frame was not attached');
  expect(new URL(paymentFrame.url()).origin).toBe('https://pay.example.test');

  const session = await page.context().newCDPSession(paymentFrame);
  try {
    await session.send('Runtime.enable');
    const result = await session.send('Runtime.evaluate', {
      expression: 'location.origin',
      returnByValue: true,
    }) as { result: { value?: string } };
    expect(result.result.value).toBe('https://pay.example.test');
  } finally {
    await session.detach();
  }
});

The FrameLocator waits against the current iframe content, then page.frame() resolves the Frame object required by newCDPSession(). Attaching before the iframe redirects from the merchant placeholder to the payment origin can bind the session to the temporary target and create a predictable close during process swap.

Browser-wide commands belong to a browser-target session. That attachment can outlive an individual page:

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

test('keeps browser-target work independent of a page', async ({ browser, page, browserName }) => {
  test.skip(browserName !== 'chromium', 'CDP is Chromium-only');

  const session = await browser.newBrowserCDPSession();
  try {
    const before = await session.send('Browser.getVersion') as { product: string };
    await page.close();
    const after = await session.send('Browser.getVersion') as { product: string };

    expect(after.product).toBe(before.product);
    expect(browser.isConnected()).toBe(true);
  } finally {
    await session.detach();
  }
});

Do not move page-domain commands to a browser session merely to keep them alive. The protocol target must match the command. Use browser scope for browser behavior and page or frame scope for document behavior.

Diagnose detachment from one lifecycle journal

Record session, page, context, and browser transitions with a monotonic timestamp. Wall-clock time helps join external logs, while elapsed time preserves local ordering when clocks differ.

TypeScript
import type { Browser, CDPSession, Page, TestInfo } from '@playwright/test';

type Marker = {
  event: string;
  elapsedMs: number;
  pageClosed: boolean;
  contextClosed: boolean;
  browserConnected: boolean;
};

export function journalCdpLifecycle(
  session: CDPSession,
  page: Page,
  browser: Browser,
  testInfo: TestInfo,
) {
  const started = performance.now();
  const markers: Marker[] = [];

  const mark = (event: string) => markers.push({
    event,
    elapsedMs: Math.round(performance.now() - started),
    pageClosed: page.isClosed(),
    contextClosed: page.context().isClosed(),
    browserConnected: browser.isConnected(),
  });

  session.once('close', () => mark('session-close'));
  page.once('close', () => mark('page-close'));
  browser.once('disconnected', () => mark('browser-disconnected'));
  mark('journal-start');

  return async () => {
    mark('journal-stop');
    await testInfo.attach('cdp-lifecycle.json', {
      body: JSON.stringify(markers, null, 2),
      contentType: 'application/json',
    });
  };
}

A page-close failure should show page-close and session-close while browserConnected remains true. Explicit detach shows session-close with an open page and context. Browser loss produces browser-disconnected, makes browser.isConnected() false, and invalidates every context and session from that browser.

A renderer crash is worth its own marker. Subscribe to page.once('crash', ...) in the same journal. A crashed page can remain represented by a Page object even though operations fail, so page.isClosed() alone does not classify it. If page-crash precedes session-close while the browser stays connected, replacing only the CDP session is not a recovery. The document target itself is unhealthy.

Read the journal as a transition, not a snapshot

Start with the event field because it tells you what caused each state sample to be taken. A healthy opening record has event set to journal-start, with pageClosed: false, contextClosed: false, and browserConnected: true. Those values establish that the owner was live when observation began. They do not promise that the next command will succeed.

Read pageClosed at the scope of the attachment. true is conclusive for a page-target session: that page cannot accept a replacement session. false is potentially misleading. It is also the value you can see when a frame target has changed underneath an open top-level page, when code detached deliberately, or when the session-close callback ran before the later page-close marker was appended. The deciding evidence is the next lifecycle record and the identity of the page or frame the helper intended to own.

contextClosed: true moves the recovery boundary above every page in that context. A healthy false only says that the container remains available. It does not make a closed popup or replaced frame usable. In the same way, browserConnected: false is decisive evidence of transport loss, while true can accompany a dead renderer, a closed page, a released context, or a deliberately detached session. Treat the false value as a broad failure and the true value as permission to inspect narrower layers, not as a health certificate.

The elapsedMs value has no universal healthy threshold. Healthy output is nondecreasing and preserves the array's insertion order. Two records can carry the same rounded value, so equality does not mean the events were simultaneous. A long interval is not itself a fault either. Join the journal to a wall-clock anchor when comparing it with browser-service or operating-system logs, then use elapsedMs only to order events within the attempt.

The earlier close record's pageUrl is an identity hint. The expected application URL beside live booleans is useful; null means the logging code observed an already closed page. A plausible URL can still mislead because a crashed page may retain its last address and an attachment made before a redirect may describe the temporary document. Confirm identity with the user action and trace rather than treating a well-formed URL as proof of liveness. Likewise, the last entry in recentProtocolEvents is merely the last event received. It is not a protocol-provided reason for the close.

Separate timeout teardown from target loss

A test timeout is a second failure mode that can leave almost the same visible tail as spontaneous target loss. A protocol call is pending, the test body exceeds its budget, and Playwright Test proceeds to the cleanup phase for test-scoped fixtures. Releasing the page or context closes the attached session, so the pending send() rejects and the close journal ends with the same booleans a deliberate cleanup would produce. The root cause is the earlier timeout or a slow fixture, not a renderer transition.

The separating evidence lives before the rejection. In a timeout-driven case, the test report records the timeout as the primary error, then a teardown marker from the owning fixture appears before page-close or session-close. There is no earlier page crash, application action that intentionally closes the target, or browser disconnect. In a target-loss case, the lifecycle marker arrives while the test body is still active, and the timeout, if one appears later, is a consequence of code waiting for evidence that can no longer arrive.

Put a teardown-start marker in the fixture that owns the raw session, immediately after control returns from its use boundary and before it releases session resources. That marker is more precise than a console line in a distant afterEach, because fixture dependencies determine teardown order. Also retain the runner's first error instead of reporting only the last rejected promise. If the first error says the test timed out and the journal shows teardown-start before closure, creating a replacement session would fight the runner's cleanup and can make the real slow operation harder to find.

Do not classify every failure near the configured timeout as timeout teardown. The browser can fail first and leave the test waiting until its budget expires. The required ordering is primary runner error or owner teardown marker first, then target and session closure. A close or crash marker first points back to the browser or application boundary even when the final line in the report is a timeout.

For long protocol sequences, retain a bounded list of method names rather than every event payload. The generic event notification is available in Playwright 1.59 and later:

TypeScript
const recentProtocolEvents: Array<{ method: string; elapsedMs: number }> = [];
const started = performance.now();

session.on('event', ({ method }) => {
  recentProtocolEvents.push({
    method,
    elapsedMs: Math.round(performance.now() - started),
  });
  if (recentProtocolEvents.length > 25)
    recentProtocolEvents.shift();
});

Method names can show that Page.frameDetached or a final network event arrived immediately before closure, but they are not a reason code. Avoid attaching all raw params by default. CDP payloads can contain URLs, headers, console arguments, script results, and user data. Capture only the domains needed for the investigation, redact deliberately, and cap the buffer so a busy page cannot create a massive artifact.

Run the failing case with one worker, no retry, and a Playwright trace:

Shell
npx playwright test tests/cdp/session-lifecycle.spec.ts \
  --project=chromium --workers=1 --retries=0 --trace=on

Trace Viewer can prove which Playwright action closed a page, whether a click opened a replacement, and what the DOM looked like around the failure. It does not replace the lifecycle journal for raw CDP ownership. Attach the journal even when the test passes on retry, and keep each attempt's artifacts separately.

The nearest competing cause is often protocol misuse rather than detachment. Chromium can reject a valid session command because the method is unavailable on that target, a domain was not enabled, or parameters are invalid. In that case the session close marker never appears, the page stays open, and a later valid command can still succeed. Creating a new session only discards useful state and repeats the same bad command.

Another near-miss is an ordinary Playwright locator failing after the page closes. The CDP helper may be blamed because it ran earlier, but the trace can show application code or test cleanup closing the page first. Compare the first closure marker with the first failing operation. Later exceptions are consequences, not independent root causes.

For a remote browser connected through chromium.connectOverCDP(), a lost transport is broader than a target detach. browser.isConnected() becomes false and the disconnected event fires. Reusing contexts from that Browser object is invalid. Reconnect only if the remote browser service contract says the same browser is still running and exposes a fresh endpoint. In Playwright Test, ending the attempt and letting a new worker establish clean fixtures is usually safer.

Add recovery only where replay is safe

Introduce the close event and journal before adding any replacement logic. Run the current suite and classify closures by explicit cleanup, expected target closure, unexpected target replacement, and browser disconnect. This observation phase shows whether recovery would address a real transient boundary or merely conceal ownership bugs.

Wrap read-only operations first. A version query or DOM read can be repeated on a confirmed live replacement target. Input dispatch, script installation, network interception, and storage mutation need command-specific replay rules. Record whether the original command received a response and whether its effect is observable before sending it again.

When replacement is allowed, rebuild all session-local setup in one function: domain enables, event listeners, throttling state, and any protocol configuration. Do not spread reinitialization across callers. A fresh object that lacks one listener can pass the immediate command and silently lose the evidence the test was designed to collect.

Write a replay table before implementing the branch:

OperationSafe after confirmed explicit detach?Required proof
Browser.getVersion readUsuallyBrowser remains connected and a new browser session succeeds
Runtime.evaluate pure readSometimesSame intended page is open and document identity still matches
Network event subscriptionNo replay of missed historyNew session is ready before a new trigger
Cache or storage mutationNot blindlyOriginal effect is measured and duplicate execution is acceptable
Input dispatchNot blindlyProduct state proves the input did not already land
Screenshot captureUsuallyTarget still represents the state the case intends to preserve

"Pure read" is narrower than a JavaScript expression that happens to return a value. Runtime.evaluate can click elements, write storage, call APIs, or mutate global state. Review the expression itself before classifying it as repeatable.

Migrate an existing suite without changing its assertions

Begin with a call-site inventory, not a global catch block. For each raw protocol helper, record whether it owns a page, frame, or browser session; which domains it enables; which listeners must exist before the trigger; and whether the command is observational or mutating. Also record the product action that may replace the target. This exposes helpers whose apparent test scope is wider than the page they actually attach to.

Turn the observation phase into an artifact contract. Freeze the attempt identity, owner scope, ordered lifecycle records, and intentional fixture-teardown marker, then confirm the CI report retains them on failed and retried attempts. Keep artifact names and field meanings stable so failures from different workers can be compared. A new recovery branch and a new logging format in the same change make it impossible to tell whether fewer visible errors came from correct replacement or lost evidence.

Move construction and cleanup behind the owner next, while preserving every caller's result shape and failure behavior. The first breakages usually come from consumers that cached the raw CDPSession, enabled a domain in one helper but installed its listener in another, or performed cleanup outside the owning fixture. Treat those failures as migration findings. Adding recovery at this point would preserve the leaked lifetime and make the ownership defect intermittent.

Before enabling replacement in CI, land characterization cases for each allowed and refused branch. An allowed case must prove the intended target is still live, rebuild setup, and return the same semantic result as the old path. A refused case must retain the original error when the page, context, browser, or command intent makes replay unsafe. Keep retries disabled for these cases so an independent second attempt cannot masquerade as in-process recovery.

Define acceptance evidence for the first eligible helper before enabling its replacement branch, and choose one whose result is already asserted by the test. Inspect both successful replacements and refusals. A green result alone is weak evidence because a replacement can miss the event window and still let later assertions pass. The change is working when each replacement has a preceding classified close, the rebuilt session produces the expected observation, refused cases remain failures with complete artifacts, and ordinary passing cases report no recovery. Only then move another helper, preserving command-specific policy rather than widening one generic retry path.

The rollout also needs a reversible boundary. Keep the old fail-fast behavior available through the suite's existing configuration mechanism until the canary has exercised expected closures. If recovery volume rises after a product or browser change, return that helper to fail-fast behavior while retaining the journal. This preserves diagnosis without forcing the entire suite back to uninstrumented sessions.

Roll out replacement behind one helper and record a counter for each attempted recovery, successful replacement, and refused recovery. A rising recovery count is a regression signal even if tests stay green. Set a small command-specific limit, normally one replacement within one observation window, so a target that repeatedly churns cannot trap the test in a reconnect loop.

The costs are real. Journaling adds attachments and code paths. Recreating sessions adds latency and can miss events between detachment and reattachment. Keeping a browser-level session alive increases the scope of raw protocol access. Remote reconnection can preserve contaminated browser state. Limit every mechanism to the narrowest target and retain a reason for each replay.

Make the latency cost visible in suite planning. As an illustrative calculation, not a measured Playwright figure, suppose attachment, domain enablement, and listener restoration consume 80 milliseconds, and 200 cases each recover once. That is 16 seconds of serial-equivalent setup work before any product action is repeated. Parallel workers can hide some wall-clock delay, but they do not remove browser CPU, remote service traffic, or artifact storage. Measure the helper in your environment and budget from that result.

Coverage has a sharper cost than latency. Events emitted between the old close and the new listener registration are absent permanently, so replacement narrows the observable window. Complexity also becomes ongoing maintenance: every new domain enable, listener, or protocol option must be added to the single reinitialization path and tested on both initial and replacement sessions. If a case must observe an uninterrupted request stream, fail the case at detachment instead of buying a green result with a gap.

Assign ownership before handing off the incident

The automation infrastructure owner should maintain the session wrapper, lifecycle artifact schema, replay classification, and fixture cleanup order. The feature-test owner should decide whether a popup, navigation, or frame replacement is expected product behavior and whether repeating the specific observation preserves the assertion. A browser or CI platform owner takes the case when the evidence shows a renderer crash, process exit, resource pressure, or browser transport disconnect. For a remotely hosted browser, the service owner also needs to determine whether the endpoint disappeared while the browser survived.

The handoff should contain the test and project identity, attempt number, session scope, intended page or frame identity, and the user action immediately before closure. Include the ordered journal with its wall-clock anchor, the first runner error, recent protocol method names, trace, and the browser and Playwright versions used by that attempt. State whether cleanup had started, whether recovery was attempted or refused, and whether the issue reproduces with one worker and no test retry. Redact protocol parameters and remote endpoint credentials rather than omitting the lifecycle sequence.

Ownership should follow the earliest proven boundary, not the team that owns the last stack frame. A feature team should not receive a bare session.send() rejection when browserConnected already became false. The platform team should not receive a renderer-crash ticket when the fixture teardown marker preceded every close. If evidence cannot separate those paths, the automation owner keeps the issue long enough to improve instrumentation and produce a discriminating run.

Do not add recovery when the page is supposed to close and the test has already observed its final outcome. Detachment is then normal cleanup. Do not recover from a browser crash inside a product assertion; a new browser would be a new attempt with different state. Do not use CDP at all when Playwright exposes a stable cross-browser API for the same behavior.

Know what this technique does not catch

Lifecycle journaling does not detect a semantically stale read from the correct live target. A protocol command can return successfully while the application is still showing its previous state, so pageClosed, contextClosed, and browserConnected all remain healthy and no close marker appears. Catch that failure with a product readiness condition and an assertion on the returned value. Replacing the session is irrelevant because ownership and transport never failed.

Finally, do not convert a detached-session failure into an unconditional flaky retry. A retry can launch a clean browser and pass, but the report then loses whether a popup closed too early, a fixture detached twice, or the browser crashed. Preserve attempt zero, keep the lifecycle journal, and let recovery happen only at the boundary the evidence proves is still alive.

// 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

Can a detached CDP session be attached again?

The detached object cannot be reused. If its Chromium target is still alive, create a new session for that page or frame and enable the protocol domains the new session needs.

How do I know whether the page or browser closed?

Record the session `close` event beside `page.isClosed()` and `browser.isConnected()`. A closed session with a live page and connected browser has a much smaller recovery scope than a browser transport loss.

Does a navigation always detach a page CDP session?

Ordinary same-target navigation does not justify assuming detachment. Target replacement, frame process changes, page closure, browser failure, or explicit cleanup can close a session, so use lifecycle evidence instead of the URL change alone.

Should I retry a command after the CDP session closes?

Only retry after classifying the owner and creating a valid replacement session. Replaying a mutating protocol command blindly can perform the operation twice or move the test past the event it was meant to observe.

Why does CDP code work in Chromium but fail in Firefox?

Chrome DevTools Protocol sessions are supported only for Chromium-based browsers in Playwright. Keep the case in a Chromium project or express the requirement through a cross-browser Playwright API when one exists.