Version
1.58.2
Steps to reproduce
playwright-core/lib/server/utils/happyEyeballs.js creates a module-level singleton http.Agent({ keepAlive: true }), shared by every request context in a worker. Node's agent does not parse the response's Keep-Alive: timeout=N header, so it will hold and reuse a socket the server has already committed to closing. The reuse is answered with a RST, which surfaces as apiRequestContext.<verb>: read ECONNRESET or socket hang up.
Reproduced against a Next.js 16 dev server, which is Node's HTTP server with the default keepAliveTimeout of 5s. It advertises the value on every response (Keep-Alive: timeout=5), and a raw socket idled against it is closed at 6.06s.
I swept request timings across that boundary:
- idle server: 0 failures in 228 requests
- same sweep, six concurrent readers: 2 failures in 87, both landing exactly at the 6000ms mark
The server was three hours old and 154 MB resident for both sweeps, so this is not memory pressure or server degradation. Concurrency widens the window between the client's decision to reuse a pooled socket and the write reaching the server; it is not itself the cause.
Expected behavior
A pooled socket is not reused after the server's advertised Keep-Alive: timeout has elapsed, so an idempotent request does not fail with ECONNRESET.
Actual behavior
The request throws read ECONNRESET.
What makes this hard to attribute: Chromium's network stack resends a request that failed on a socket it had reused, so page navigations never show this. Only APIRequestContext calls do. In a suite that mixes both, the failures cluster in whichever specs happen to use the request context, which reads as a bug in those specs rather than as a transport fault. It took a packet-level repro to see otherwise.
The consequence in our case was worse than a flaky test. A dropped cleanup PATCH at the end of a spec left fixture data in a shared database, and because our seed only creates what is absent, that state persisted and poisoned later runs.
Suggested fixes, in preference order
- Honour the
Keep-Alive: timeout header when deciding whether a pooled socket is still usable.
- Default
maxRetries above 0 for idempotent verbs.
- Document the hazard on
APIRequestContext. maxRetries defaults to 0 and nothing in the docs points a user toward it for this failure.
Workaround
Wrap the request context so every idempotent verb carries maxRetries. That is narrow and safe: fetch.js retries only when e.code === 'ECONNRESET' and a status code never reaches that catch, so no real server failure is masked. We deliberately excluded POST, since the reset is raised on the read side and the server may already have processed the create, making a replay a duplicate row.
Setting Connection: close via use.extraHTTPHeaders also fixes it completely (0 failures in 87 under the load that gave 2), but Chromium forwards the header, so the browser loses keep-alive for every navigation and every asset to fix a fault that only ever touched the API context.
Environment
- Playwright 1.58.2
- Node v26.3.0
- macOS (Darwin 25.4.0), arm64
- Server under test: Next.js 16.2.11 dev server
Version
1.58.2
Steps to reproduce
playwright-core/lib/server/utils/happyEyeballs.jscreates a module-level singletonhttp.Agent({ keepAlive: true }), shared by every request context in a worker. Node's agent does not parse the response'sKeep-Alive: timeout=Nheader, so it will hold and reuse a socket the server has already committed to closing. The reuse is answered with a RST, which surfaces asapiRequestContext.<verb>: read ECONNRESETorsocket hang up.Reproduced against a Next.js 16 dev server, which is Node's HTTP server with the default
keepAliveTimeoutof 5s. It advertises the value on every response (Keep-Alive: timeout=5), and a raw socket idled against it is closed at 6.06s.I swept request timings across that boundary:
The server was three hours old and 154 MB resident for both sweeps, so this is not memory pressure or server degradation. Concurrency widens the window between the client's decision to reuse a pooled socket and the write reaching the server; it is not itself the cause.
Expected behavior
A pooled socket is not reused after the server's advertised
Keep-Alive: timeouthas elapsed, so an idempotent request does not fail with ECONNRESET.Actual behavior
The request throws
read ECONNRESET.What makes this hard to attribute: Chromium's network stack resends a request that failed on a socket it had reused, so page navigations never show this. Only
APIRequestContextcalls do. In a suite that mixes both, the failures cluster in whichever specs happen to use the request context, which reads as a bug in those specs rather than as a transport fault. It took a packet-level repro to see otherwise.The consequence in our case was worse than a flaky test. A dropped cleanup
PATCHat the end of a spec left fixture data in a shared database, and because our seed only creates what is absent, that state persisted and poisoned later runs.Suggested fixes, in preference order
Keep-Alive: timeoutheader when deciding whether a pooled socket is still usable.maxRetriesabove 0 for idempotent verbs.APIRequestContext.maxRetriesdefaults to 0 and nothing in the docs points a user toward it for this failure.Workaround
Wrap the request context so every idempotent verb carries
maxRetries. That is narrow and safe:fetch.jsretries only whene.code === 'ECONNRESET'and a status code never reaches that catch, so no real server failure is masked. We deliberately excludedPOST, since the reset is raised on the read side and the server may already have processed the create, making a replay a duplicate row.Setting
Connection: closeviause.extraHTTPHeadersalso fixes it completely (0 failures in 87 under the load that gave 2), but Chromium forwards the header, so the browser loses keep-alive for every navigation and every asset to fix a fault that only ever touched the API context.Environment