October 25, 20265 min readbrowserpuppeteerengineering
puppeteer.connect() Hangs Forever. Here Is Why, and How to Tell.
A puppeteer connect that never resolves while raw CDP answers instantly is usually one target that never attached, not a dead browser. How to tell.
By Scrappy Team
If you run a fleet of Chrome instances and connect to them with puppeteer.connect(), you will eventually see this: the connect call never resolves. No error, no timeout, nothing. Meanwhile a raw Chrome DevTools Protocol socket to the exact same endpoint answers Browser.getVersion in under 100 milliseconds.
The browser is fine. Something about the connect is not. We restarted healthy machines over this for a while before we understood it, so here is what we learned.
What connect() is actually waiting for
puppeteer.connect() does not finish when the WebSocket opens. In puppeteer 23.6, the target manager's initialize() holds the connect open until every existing page target — and the iframe targets under them — has attached. If one target never reports that it attached, the connect waits for it. Indefinitely.
And because no CDP command is outstanding while it waits, protocolTimeout never fires. That setting bounds commands, not this wait. So the promise never settles.
In our case the stuck target belonged to one session on one site. Our wedge detector saw "puppeteer cannot connect, but Chrome answers raw CDP" — which was exactly our definition of a wedged browser — and restarted the machine. Five times, each one or two minutes after someone signed in to the same site, with Chrome's CPU at zero. A restart does fix it, in the sense that it destroys the page that was holding the target. It also destroys everyone else's session.
How to tell a stuck target from a stuck browser
Retry the connect with a filter that waits on nothing:
await puppeteer.connect({
browserWSEndpoint,
targetFilter: () => false, // attach to no existing target
});
If that connects promptly while the full connect hangs, the browser is healthy and one target is the problem. Log every target Chrome lists at that moment — type and origin — and you will usually be able to name the page. If the filtered connect hangs too, the browser really is in trouble.
Since we added that check, the probe that used to restart machines logs one line instead, and only a connect that fails with nothing attached counts toward a restart.
Close the socket yourself
The second thing we learned is about cleanup. A connect that times out in your own code — a Promise.race against a timer, say — is abandoned, not cancelled. In puppeteer 23.6 a failed connect disposes nothing, so its WebSocket stays open, with a DevTools client still auto-attached to every target. We measured one leaked socket per failed attempt: one, two, three, for as long as the hang lasted.
The fix is to own the transport. Open the WebSocket yourself, hand puppeteer a transport built on it, and close it on any failure. If you do that, also give the resulting Browser its real endpoint: over a supplied transport, puppeteer reports wsEndpoint() as an empty string, and anything that later dials that endpoint gets nowhere.
The targetFilter that broke every boot
Having learned that attaching to targets is where connects go wrong, we tried to attach to fewer of them. Our first version allowed only page and browser targets. It passed its unit tests. In production, every browser boot hung on its first connect.
The reason is in how Chrome nests targets. Puppeteer's browser-level auto-attach attaches to the tab target that owns each page, and the page then attaches underneath the tab's session. A filter that rejects the tab has it detached immediately — so the page under it never attaches, and initialize() waits for it forever.
Measured against Chrome with puppeteer-core 23.6: the page-and-browser allow-list timed out at six seconds. Excluding only the target types we genuinely did not need — shared workers and service workers — connected in 18 milliseconds, with the same pages.
Two rules came out of that:
- Never reject a target type that a page can attach under. Exclude the specific types you do not want; do not allow-list the ones you think you do. Anything puppeteer reports as
othercan be a parent. - A test that checks the option was passed proves nothing about what Chrome does with it. The check that would have caught this takes two minutes: run a real
puppeteer.connectwith the filter against a local Chrome, raced against a timer.
The short version
- A connect that hangs while raw CDP answers is usually one target that never attached, not a dead browser.
- Retry with
targetFilter: () => falsebefore you conclude anything, and log the target list. - Own the WebSocket, and close it when a connect fails.
- If you filter targets, exclude rather than allow, and measure against a real browser.
This is the kind of thing Scrappy's cloud browser handles so the code driving it does not have to. If you would rather run your own fleet, we hope this saves you the restarts it cost us.
Keep reading
A JavaScript Dialog Looks Exactly Like a Frozen Tab
Every CDP command on the tab times out, CPU is at zero and the screen shows a normal page. Before you call it a hung renderer, rule out a native dialog.
proxiesYour Proxy Provider Replaced the IP. Your Config Still Has the Old One.
A static residential IP was replaced overnight, the old address answered 407, and every login pinned to it failed. What we changed so it cannot recur.
scrapingHeadless Chrome Is Why You're Getting Blocked
You can patch every fingerprint you know about and still be detected, because headless is visible on its own. What we changed after chasing this for months.