← all articles

Why a browser agent's CDP connection keeps dying mid-task

you kick off an agent run, it logs in, clicks through four pages, and then on step nine it throws Target closed or WebSocket connection closed or just hangs until a timeout fires. the browser window is still open. you can see it on screen. the agent has lost the handle on it anyway. if you run browser agents for more than a few minutes at a time, you have seen this, and you have probably “fixed” it by wrapping the whole run in a retry that starts from step one.

this piece is my answer to the reader question behind browser-use issue #2819, which is about exactly this: the CDP connection dying in the middle of a task. the question is “why does it drop, and what should the agent do when it does”. the short version is that “the CDP connection” is really three different things stacked on top of each other (a socket, a browser-level session, and a target session), and each layer dies for different reasons and needs a different recovery. most agent frameworks treat all three as one thing and collapse when any of them goes.

the stakes are mostly money and state. a dropped connection at step nine of a twelve step flow means you either lose the work, or you replay it and risk doing a side effect twice (a second form submit, a second order). if the agent is a vision loop that pays per screenshot, every restart from scratch pays for the early steps again. i wrote about that cost shape in cost engineering for computer-use agents. and if you are running a fleet, a 2 percent per-step disconnect rate compounds into a lot of failed runs over a long task. the full index of our other agent write-ups is at /blog/.

background and prior art

the Chrome DevTools Protocol (CDP) is a JSON message protocol that Chromium exposes over a websocket. every message is either a command with an id that gets a reply, or an event with no id. the protocol reference is maintained by the Chrome team and is the only place i trust for domain and method names. puppeteer was built directly on it, Playwright speaks it for Chromium (and uses its own protocols for Firefox and WebKit), and browser-use sits on top of Playwright and, in newer versions, talks to CDP more directly. if a method has moved, check the current protocol viewer, not my snippets.

the history that matters for this problem is that CDP was designed for a developer sitting at a laptop with DevTools open, one websocket, one machine, localhost. agents broke every one of those assumptions. the browser is now in a container, or on a remote host, or behind a cloud vendor’s gateway. the run lasts an hour, not a debugging session. and the thing on the other end is not a human who notices the drop and clicks reconnect. the prior art for “reconnect well” mostly lives in the websocket world generally, for example RFC 6455, which defines ping and pong control frames and the close handshake, and in the Playwright docs for connectOverCDP. what is missing from all of it is guidance on what to do at the session layer after the socket comes back, which is where agents actually lose their place.

the core mechanism

the three layers

when people say “the CDP connection died” they mean one of these.

  • the transport: the websocket between your agent process and the browser’s debugging endpoint. this is the thing that closes with a code like 1006 or 1001.
  • the browser session: the logical connection to the browser as a whole, identified by the browser-level websocket URL (ws://host:9222/devtools/browser/<uuid>). if the browser process restarts, this URL is gone for good.
  • the target session: one attachment to one tab, worker, or iframe, identified by a sessionId that you get back from Target.attachToTarget. a tab can crash, navigate across a process boundary, or get closed while the transport and browser are fine.

a transport can die while the browser lives (you can reconnect and re-attach). a target can die while the transport lives (you open a new tab). a browser can die and take everything with it (you launch again and lose all state). a self-healing agent has to know which of the three it lost, because the recovery for each is different.

how the url and the sessions are created

you get the browser websocket URL from the HTTP endpoint /json/version, in the field webSocketDebuggerUrl. the UUID in that URL is generated when the browser launches. it does not survive a browser restart. so any config that hardcodes the full ws:// URL will work on the first run and fail forever after the first browser restart. you want to resolve it fresh from /json/version on every connect.

once connected at the browser level, you list targets with Target.getTargets or subscribe with Target.setDiscoverTargets, then attach with flattened sessions:

{"id": 7, "method": "Target.attachToTarget",
 "params": {"targetId": "7F3A...", "flatten": true}}

the reply gives you a sessionId. every subsequent command to that tab carries "sessionId": "..." at the top level of the message. with flatten: true all sessions multiplex over the same websocket, which is why one dead socket kills every tab you had attached at once.

the sessionId is only valid for the life of that attachment. if you reconnect the transport, the old sessionId values are meaningless. this is the single most common bug in home-grown reconnect code: it re-opens the socket and then keeps sending commands with the old session id, and gets back {"error": {"code": -32001, "message": "Session with given id not found."}}. the socket is fine. the agent just never re-attached.

what actually severs each layer

transport drops, the most common ones i have seen:

  • idle timeouts on anything in the path. nginx defaults proxy_read_timeout to 60 seconds, and AWS Application Load Balancers default to a 60 second idle timeout (both as of October 2026, check your own config). an agent waiting on a slow LLM call between CDP commands can easily leave the socket silent for longer than that. no traffic, the middlebox closes it, and the agent finds out on the next send.
  • NAT and carrier state expiry. if the agent or the browser sits behind a mobile or CGNAT link, idle mappings get dropped on the carrier’s schedule, not yours. i covered that mechanism in carrier-grade NAT explained for AI agents.
  • laptop sleep, VPN reconnects, Wi-Fi roaming. for a local dev setup attached to a remote browser, the transport dies the moment your network path changes.
  • a very large message. CDP frames like Page.captureScreenshot or DOM.getDocument on a heavy page can be many megabytes. some websocket libraries have a default max message size and close the socket with a 1009 “message too big” code when you cross it. this one looks random until you notice it always happens on the same heavy page.
  • the browser closing it. Chromium closes the socket if the process exits, but also if a second client attaches in a way the first did not expect, or if the debugging endpoint is shut down.

browser-level deaths:

  • the browser process crashed or was OOM-killed. in Docker the default /dev/shm is 64 MB, and Chromium uses shared memory heavily; a heavy page can crash the renderer, and enough memory pressure takes the whole container’s browser with it. launching with --disable-dev-shm-usage or mounting a bigger /dev/shm is the standard fix.
  • the orchestrator recycled it. a cloud browser vendor, a Kubernetes liveness probe, or your own reaper script decided the session was too old or too idle and killed it.
  • the machine slept or the process was suspended.

target-level deaths:

  • renderer crash. the tab shows the “Aw, Snap” page. you get an Inspector.targetCrashed or Target.targetCrashed event, and the session is dead, though the browser is alive.
  • the page closed itself, or a popup handler closed it, or window.close() ran.
  • cross-process navigation. site isolation means that navigating from one origin to another can swap the renderer process behind a tab. well-behaved clients survive this because the browser-level target id stays stable, but hand-rolled clients that cache a frame-level execution context id get Runtime.executionContextDestroyed and Cannot find context with specified id errors. that is not a disconnect, but agents often report it as one.

the access rules that make a reconnect fail

these access rules look like “the connection is flaky” when they are really deterministic.

  • the Host header check. Chromium rejects debugging HTTP requests whose Host header is not localhost or an IP address. if you reverse-proxy /json/version under a hostname, you get a 500 with a message saying so. fix it at the proxy by rewriting the Host header to localhost:9222.
  • the Origin check. since Chrome 111, a websocket upgrade carrying an Origin header is rejected with a 403 unless the browser was launched with --remote-allow-origins=.... some clients send an Origin and some do not, so a reconnect through a different code path can fail where the first connect worked.
  • the user data dir rule. from Chrome 136 on, --remote-debugging-port and --remote-debugging-pipe are ignored when pointed at the default profile directory. you must pass a non-default --user-data-dir. the Chrome team’s write-up explains the security reasoning. this matters here because it is the reason “attach to my everyday Chrome” stopped working for many people in 2025, and why i point readers at a dedicated profile. i went through that trade-off in attaching to an existing Chrome session vs launching a fresh browser for your agent.

what a self-healing reconnect looks like

the shape is a small state machine, not a try/except. the agent’s loop keeps a record of what it has done, and the connection manager is responsible for getting back to a usable page.

  1. detect. do not wait for a command to fail. listen for the transport’s close event, and also send a cheap heartbeat (for example Browser.getVersion) on an interval shorter than the shortest idle timeout in your path. if the heartbeat does not return within a few seconds, treat the transport as dead.
  2. classify. after a drop, probe /json/version. if it answers, the browser is alive and you only lost the transport. if it does not answer after a short window, the browser is gone.
  3. reconnect the transport with backoff. resolve the websocket URL fresh, connect, and cap the attempts.
  4. rebuild the sessions. re-enumerate targets, find the tab you were working in, and re-attach to get a new sessionId. then re-enable the domains you rely on (Page.enable, Runtime.enable, Network.enable), because enablement is per session.
  5. verify position. do not assume the page is where you left it. read the current URL and a key element from the page, and compare against the step the agent thinks it is on.
  6. resume only what is safe. replay from the last checkpoint, but guard steps with side effects (more below).

if step 2 says the browser is gone, you are in a different recovery: relaunch, restore cookies and storage from a snapshot you took earlier, navigate back, and re-verify. that is a session lifecycle problem more than a connection problem, and i wrote about the checkpoint side of it in session lifecycles for long-running agents.

here is a compact version of steps 1 to 4 using Playwright for Python. it is a sketch to show the structure, not a drop-in library.

import asyncio, httpx
from playwright.async_api import async_playwright

CDP_HTTP = "http://127.0.0.1:9222"

async def ws_url() -> str:
    async with httpx.AsyncClient(timeout=3) as c:
        r = await c.get(f"{CDP_HTTP}/json/version")
        r.raise_for_status()
        return r.json()["webSocketDebuggerUrl"]

class Conn:
    def __init__(self, pw):
        self.pw, self.browser, self.page = pw, None, None
        self.alive = asyncio.Event()

    async def connect(self, attempts=6):
        delay = 0.5
        for i in range(attempts):
            try:
                self.browser = await self.pw.chromium.connect_over_cdp(
                    await ws_url(), timeout=10_000)
                self.browser.on("disconnected", lambda _: self.alive.clear())
                ctx = self.browser.contexts[0]
                self.page = ctx.pages[0] if ctx.pages else await ctx.new_page()
                self.alive.set()
                return
            except Exception:
                await asyncio.sleep(delay)
                delay = min(delay * 2, 8)
        raise RuntimeError("browser unreachable, escalate to relaunch")

    async def heartbeat(self, every=15):
        while True:
            await asyncio.sleep(every)
            try:
                await asyncio.wait_for(self.page.evaluate("1"), 5)
            except Exception:
                self.alive.clear()
                await self.connect()

two things to notice. the URL is resolved on every attempt, so a restarted browser with a new UUID is found. and the page is re-selected after reconnect, because the old Page object belongs to the dead connection. the heartbeat interval of 15 seconds is a number i chose to sit under a 60 second idle timeout with margin. it is a setting, not a measurement.

worked examples

these are configurations i have run or set up, described with their settings. i am deliberately not attaching failure percentages, because i have not run a controlled comparison and i would be making them up.

example 1: the 60 second silence behind a proxy

setup: a headless Chromium in a container on a VPS, with its debugging port exposed to an agent on another machine through nginx so i could put basic auth in front. the agent used an LLM call between actions, and a slow call on a long page could take 40 to 90 seconds.

symptom: runs that had a slow reasoning step died with a closed socket, while fast runs were fine. the pattern lined up with the nginx proxy_read_timeout default of 60 seconds.

fix, in two parts. first, raise the timeout on that location block and set the upgrade headers properly:

location / {
    proxy_pass http://127.0.0.1:9222;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host localhost:9222;
    proxy_read_timeout 3600s;
    proxy_send_timeout 3600s;
}

second, add the 15 second heartbeat from the sketch above, so the socket is never silent for long regardless of the proxy’s setting. i prefer doing both because the heartbeat also survives a middlebox i do not control. the Host rewrite in that block is the one from the access rules above.

example 2: the renderer crash that looked like a disconnect

setup: an agent walking a heavy single-page app in Docker, with the container started using the default 64 MB of /dev/shm.

symptom: after roughly the same page transition, the agent raised Target closed. the browser process and the websocket were both still up. querying /json/list showed the tab still listed, in a crashed state, and a Target.targetCrashed event had fired that the agent never subscribed to.

fix: run the container with --shm-size=1g (or pass --disable-dev-shm-usage to Chromium), subscribe to crash events, and make the recovery a target-level one. on a crash event, the manager opens a new tab with Target.createTarget, attaches, navigates to the last known good URL, and re-verifies. the transport stays up the whole time, so there is no backoff and the other tabs keep working. what this example teaches is the classification step: if the agent had treated this as a transport failure it would have burned its reconnect attempts reconnecting to a healthy socket.

setup: an agent controlling a Chrome instance on a remote Android phone, reached over a mobile data connection, for my own properties and test accounts.

symptom: the websocket looked open on both ends and then stopped delivering for ten to thirty seconds at a time, usually after a quiet stretch. sometimes it recovered by itself and sometimes the next send failed with a reset.

what helped: a short heartbeat (10 seconds), a command timeout on every CDP call so a stalled socket became an error within a few seconds instead of a hang, and treating a missed heartbeat as “probe and reconnect” rather than “keep waiting”. it also helped to keep the phone’s egress stable during a task. an IP change in the middle of a logged-in session can make the site you are automating invalidate the session, which then looks like the agent “lost” the page even though CDP was fine. that is why i separate the two failure families in my head: CDP death (the agent cannot talk to the browser) and site-side session death (the browser is fine but the site logged you out). for the second family, sticky sessions vs rotating IPs for AI agents covers the reasoning. if you need a stable Singapore mobile egress for your own consented test accounts, Singapore Mobile Proxy sells real Singapore mobile IPs with sticky sessions. it is Singapore-only, so it is only relevant if Singapore is the location you need. it does nothing for the CDP socket itself, which is between your agent and the browser, not between the browser and the site.

edge cases and failure modes

the replayed side effect

pitfall: the transport drops after the agent sent a click on “submit” and before it got the reply. did the click happen? you cannot tell from the connection. a naive retry clicks again and you get a duplicate submission or a second purchase.

counter-strategy: treat every state-changing step as something that needs an idempotency key or a post-condition check. before replaying, read the page state: is there a confirmation message, did the URL change, does the item now appear in the list. only replay when the post-condition says the step did not land. the general pattern is in idempotency for AI agents explained.

the stale session id after reconnect

pitfall: transport reconnects fine, the agent keeps using cached sessionId, frameId or execution context ids, and every command fails with a “session not found” or “cannot find context” error.

counter-strategy: never cache protocol-level ids across a reconnect. cache durable things instead: the tab’s URL, a stable element locator, a step counter. after reconnect, always re-attach and re-enable domains. if you use Playwright, throw away the old Page and BrowserContext handles and re-derive them from the new connection.

reconnecting into a browser that is not yours anymore

pitfall: when you attach to an existing browser, the thing you reconnect to may have changed under you. a human may have closed your tab, navigated it, or opened new ones, and contexts[0].pages[0] may now be a different page. a script that blindly takes the first page will drive the wrong tab.

counter-strategy: tag your working tab. one way is to set window.name or a marker in sessionStorage when the agent first opens it, and look for that marker on reconnect. another is to open a dedicated tab per run and record its targetId. if you cannot find the marked tab, treat it as a target-level death and rebuild, rather than silently adopting a random page.

the reconnect storm

pitfall: you run twenty agents against one browser host, the host hiccups, and all twenty reconnect at the same instant with no backoff. the debugging endpoint gets hammered, some connects time out, those retry immediately, and the host stays unhealthy longer than the original blip.

counter-strategy: exponential backoff with jitter, a hard cap on attempts, and a per-host circuit breaker so that after N consecutive failures the agents pause and let one probe go first.

the opaque error nobody logs

pitfall: the close code and reason are thrown away, so a 1009 (message too big), a 1001 (going away, usually the server shutting down), and a 1006 (abnormal closure, no close frame arrived, which is what you see when a network path vanishes) all look the same in your logs. you cannot tell a proxy timeout from a crash.

counter-strategy: log the close code, the reason string, the time since the last received message, the in-flight command method, and whether /json/version answered at the time. those five fields classify nearly every drop. a tracing setup helps a lot here, and i compared options in best tools for tracing and debugging AI agents.

what we learned in production

the biggest lesson is boring: classify before you retry. my early reconnect code was one loop that retried the whole run. it hid real problems, doubled costs by re-running early steps, and caused at least a couple of duplicate form submissions on my own test properties before i added post-condition checks. splitting the recovery into transport, browser and target levels made the logs readable. most of my drops turned out to be a silent middlebox timeout or a memory-starved container, both of which are fixable in config rather than in code.

the second lesson is that the heartbeat is worth more than the retry logic. a heartbeat that fires every 10 to 15 seconds both keeps idle timers from firing and turns a hang into a fast, classifiable failure. combined with a hard timeout on every CDP command, it changed the experience from “the agent froze for ten minutes” to “the agent noticed in under ten seconds and recovered”. if you use a framework like browser-use that wraps all this, check its timeouts and disconnect behaviour in your installed version, because they change between releases. one release that moves a default can change your drop rate with no change on your side, which is the same class of surprise i described in what silently doubled a browser agent’s token bill after a routine version upgrade.

references and further reading

Written by Xavier Fok

disclosure: this article may contain affiliate links. if you buy through them we may earn a commission at no extra cost to you. verdicts are independent of payouts. last reviewed by Xavier Fok on 2026-10-24.

free download
Why did my agent get blocked? A triage checklist

The checks we run, in order, when a browser or phone agent starts failing: network, fingerprint, behaviour, account. Leave your email and we will also tell you when we publish a new field note, a few times a month at most.

from the team behind this site
A Singapore mobile IP for browser agents

Singapore Mobile Proxy runs real mobile IPs on SingTel, StarHub and M1, with sticky sessions so one task keeps one IP. Singapore only: a fit for SEA or location-agnostic work, the wrong tool if you need a US IP.

see plans →
from the team behind this site
A real Android phone for phone-use agents

cloudf.one hosts real Android phones in Singapore on dedicated hardware, each with a persistent Singapore mobile IP. For agents that need an actual device and a stable carrier identity.

get a phone →
read on
More from The Agent Ops Report

Blocks, sessions, retries, traces, cost per task and phone-use agents. Browse all articles →