[Bug] initializeServer hangs 30s when any background tab is discarded by Chrome Memory Saver (--cdp-endpoint on a real profile)

Author: q7793527Created Sep 17, 2026Updated Sep 17, 2026

Package: MCP (@playwright/mcp 0.0.81) · Component: CDP connection / --cdp-endpoint

Summary

When --cdp-endpoint points at a long-lived real Chrome profile, the MCP server never finishes initializing if any pre-existing tab has been discarded/frozen by Chrome's Memory Saver. Every tool call fails identically:

### Error
TimeoutError: async initializeServer: Timeout 30000ms exceeded.
Call log:
  - <ws connecting> ws://127.0.0.1:9222/devtools/browser
  - <ws connected>  ws://127.0.0.1:9222/devtools/browser

The browser itself is perfectly healthy — a raw CDP client gets answers in single-digit milliseconds on the exact same URL. connectOverCDP() is blocked by one zombie target, so from the MCP side this is unrecoverable: no tool ever runs, and the error message points at MCP startup rather than at the real cause.

Environment

OS Windows 11 (64-bit)
Chrome 153.0.8010.48, default user-data-dir
Debug route chrome://inspect → "Allow remote debugging for this browser instance"
@playwright/mcp 0.0.81 (bundled playwright-core 1.64.0-alpha-2026-09-14)
Launch line npx -y @playwright/mcp@latest --cdp-endpoint ws://127.0.0.1:9222/devtools/browser

Steps to reproduce

  1. Use your normal Chrome profile with 3–4 tabs open, then leave them in the background until Memory Saver discards them (chrome://discards shows Discarded; ~10 min idle was enough here).
  2. Enable CDP through the chrome://inspect toggle. <profile>/DevToolsActivePort then contains 9222 + /devtools/browser/<uuid>.
  3. Start MCP with --cdp-endpoint ws://127.0.0.1:9222/devtools/browser.
  4. Call any tool (browser_snapshot, browser_tabs list, …).

Actual vs expected

Actual: 30 s timeout on every call, forever. Closing/reopening the MCP session does not help — the discarded tab is still there.

Expected: connect succeeds; targets whose renderer never answers are reported as degraded (or skipped after a bounded wait) instead of blocking initialization indefinitely.

Evidence

Three measurements against one endpoint:

client result
raw WebSocket + Browser.getVersion 3–9 ms, replies Chrome/153.0.8010.48
chromium.connectOverCDP('ws://…/devtools/browser') timeout (20 s, and 30 s via MCP)
chromium.connectOverCDP('ws://…/devtools/browser/<uuid>') (path read from DevToolsActivePort) timeout — so the missing UUID is not the issue

Per-target probe (Target.setAutoAttach {flatten:true}, then Runtime.enable per session):

alive    page  http://127.0.0.1:3080/                                        <- only non-discarded tab
DEAD     page  https://github.com/dream-num/dsh-univer-office/releases
DEAD     page  https://mp.weixin.qq.com/s/p30gUreI4HxF7Uvs8GDV4g
DEAD     page  https://github.com/deepseek-ai/deepseek-harness/releases
ok       browser_ui / background_page / service_worker  (extensions, omnibox, tab-search)

Note that Target.attachToTarget succeeds for a discarded tab and hands back a sessionId; every session-bound command on it (Runtime.enable, Page.enable) is then silently unanswered — no error, no Target.targetDestroyed, nothing. That silence is what makes this so hard to diagnose from the outside.

Causal proof — wake the tabs, retry the connect in the same process/endpoint:

=== phase 1: wake every page target ===
  awake    http://127.0.0.1:3080/
  awake    https://github.com/dream-num/dsh-univer-office/releases
  awake    https://mp.weixin.qq.com/s/p30gUreI4HxF7Uvs8GDV4g
  awake    https://github.com/deepseek-ai/deepseek-harness/releases

=== phase 2: Playwright connectOverCDP, same endpoint as @playwright/mcp ===
CONNECTED in 2708 ms | version=153.0.8010.48
pages=4   (all four titles readable, incl. the two GitHub ones)

One subtlety worth flagging: focus was handed back to the original tab before phase 2, so those three tabs were still in the background and Playwright connected fine. The deciding factor is renderer liveness, not foreground/background — which means "just skip hidden tabs" would not be a correct fix.

Relation to existing reports

  • #41093 (Edge 148, connect_over_cdp hangs right after <ws connected>) is closed via PR #41128 — but that PR was never merged; a Playwright maintainer describes it as "the fix that we ended up not landing" over in #41714. On 1.64.0-alpha-2026-09-14 the connect-time hang is still fully reproducible; here the trigger is a Memory-Saver discard rather than a first navigation that never commits.
  • #41714 reports the same root cause from @playwright/cli (one discarded tab wedges every command, because Tab.headerSnapshot() awaits page.title() with no timeout). Its proposed fix PR #41733 is also closed without merging (merged: false). That path would not unblock me either way: in the MCP case the code never gets that far, since I cannot even obtain a Browser object.

Suggested fixes

  1. Bound per-target initialization during connect (Promise.race against the existing timeout) so one unresponsive renderer cannot block connectOverCDP; expose such pages as degraded.
  2. Fail with an actionable message. The only symptom today is async initializeServer: Timeout 30000ms exceeded. If the client noticed "attached to N targets, M never answered Runtime.enable: , ", a 30-second mystery becomes a one-line diagnosis.
  3. Secondary — HTTP discovery fallback. On the chrome://inspect toggle route Chrome serves WebSocket only: /json, /json/version and /json/list all return 404, so connectOverCDP('http://127.0.0.1:9222') dies in ~10 ms with Unexpected status 404 when connecting to http://127.0.0.1:9222/json/version/. Falling back to /json/list, and/or reading <user-data-dir>/DevToolsActivePort, would cover it — same finding as vercel-labs/agent-browser#628.

Workarounds (currently needed to use MCP at all)

  • Disable Memory Saver in chrome://settings/performance, or add the sites to "Keep these sites active".
  • Use a dedicated profile: chrome.exe --user-data-dir=D:\chrome-dsh --remote-debugging-port=9222. That route serves /json/version 200, needs no per-connection approval, and is unaffected by Chrome ≥136 refusing the flag on the default profile dir (official note).
  • Target.activateTarget the tabs before connecting (works, but you have to know this already).

Minimal repro

Node 24 (global WebSocket), no MCP required — run it once with discarded tabs, then again after clicking each tab:

javascript
// npm i [email protected]
// node repro.mjs ws://127.0.0.1:9222/devtools/browser
const { chromium } = require('playwright-core');
const ENDPOINT = process.argv[2];

async function rawProbe() {
  const ws = new WebSocket(ENDPOINT);
  await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; });
  const pending = new Map(); let id = 0;
  ws.onmessage = (e) => { const m = JSON.parse(e.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } };
  const call = (method, params, sessionId, wait = 2500) => new Promise((res) => {
    const i = ++id; const t = setTimeout(() => { pending.delete(i); res({ timeout: true }); }, wait);
    pending.set(i, (m) => { clearTimeout(t); res(m); });
    ws.send(JSON.stringify(sessionId ? { id: i, sessionId, method, params } : { id: i, method, params }));
  });

  const v = await call('Browser.getVersion');
  console.log('raw Browser.getVersion:', v.timeout ? 'NO REPLY' : v.result.product);

  const attached = [];
  ws.onmessage = (e) => { const m = JSON.parse(e.data);
    if (m.method === 'Target.attachedToTarget') attached.push(m.params);
    else if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } };
  await call('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: false, flatten: true });
  await new Promise(r => setTimeout(r, 1500));
  for (const a of attached) {
    const r = await call('Runtime.enable', {}, a.sessionId);
    console.log(`  ${(r.timeout ? 'DEAD' : r.error ? 'err' : 'alive').padEnd(6)} ${a.targetInfo.type.padEnd(13)} ${a.targetInfo.url.slice(0, 64)}`);
  }
  ws.close();
}

(async () => {
  await rawProbe();
  const t0 = Date.now();
  try {
    const b = await chromium.connectOverCDP(ENDPOINT, { timeout: 20000 });
    console.log(`connectOverCDP: OK in ${Date.now() - t0} ms, pages=${b.contexts().flatMap(c => c.pages()).length}`);
    await b.close();
  } catch (e) {
    console.log(`connectOverCDP: FAILED in ${Date.now() - t0} ms — ${String(e.message).split('\n')[0]}`);
  }
})();

Output on the affected profile, verbatim:

raw Browser.getVersion: Chrome/153.0.8010.48
  alive  page          http://127.0.0.1:3080/
  DEAD   page          https://github.com/dream-num/dsh-univer-office/releases
  DEAD   page          https://mp.weixin.qq.com/s/p30gUreI4HxF7Uvs8GDV4g
  DEAD   page          https://github.com/deepseek-ai/deepseek-harness/releases
connectOverCDP: FAILED in 20011 ms — browserType.connectOverCDP: Timeout 20000ms exceeded.

Source: microsoft/playwright-mcp