Timed-out WebSocket sessions look like a crash to the client (no real WS close frame is sent)

Author: devsachin0879Created Sep 18, 2026Updated Sep 18, 2026

Bug description:

When a WebSocket/CDP session hits the configured TIMEOUT, browserless does try to tell the client - onWebsocketTimeout in src/router.ts calls writeResponse(socket, 408, 'Request has timed out') - but by the time a session is active, that socket is no longer a plain HTTP connection. It's already being piped bidirectionally to the real Chrome instance by http-proxy inside ChromiumCDP.proxyWebSocket (src/browsers/browsers.cdp.ts), so it's mid-stream WebSocket frame data at that point, not raw HTTP.

writeResponse doesn't know that. For a Duplex target it always falls back to the same code path (src/utils.ts):

typescript
const httpResponse = [
  httpMessage.message,
  `Content-Type: ${contentTypeHeader}`,
  'Content-Encoding: UTF-8',
  'Accept-Ranges: bytes',
  'Connection: keep-alive',
  '\r\n',
  body,
].join('\r\n');

writeable.write(httpResponse);
writeable.end();

That writes a raw, unframed HTTP/1.1-style text block directly into an already-upgraded WebSocket stream, then calls socket.end(). There's no socket.close(code, reason) — no real WebSocket close frame with a code or reason the client's WS implementation can parse. From Puppeteer's or Playwright's point of view, the connection just breaks with malformed data, which surfaces as the same generic error you'd get from an actual crash (e.g. Protocol error, Target closed) — there's no way for calling code to distinguish "the server intentionally closed this because of TIMEOUT" from "something crashed."

To Reproduce:

  1. Start browserless with a short timeout, e.g. TIMEOUT=5000
  2. Connect over CDP or Playwright and open a session that runs longer than the timeout — a page.waitForTimeout(10000) after connecting is enough
  3. Observe the error the client library surfaces once the timeout fires
javascript
import puppeteer from 'puppeteer-core';

const browser = await puppeteer.connect({
  browserWSEndpoint: 'ws://localhost:3000?token=YOUR_TOKEN',
});
const page = await browser.newPage();
await page.goto('https://example.com');
await new Promise((r) => setTimeout(r, 10000)); // outlives TIMEOUT=5000
await page.title(); // throws here

Ran this against chromium with TIMEOUT=5000. The container logs show the timeout firing correctly server-side:

browserless.io:limiter:warn   Job has hit timeout after 5,001ms of activity.
browserless.io:limiter:debug  Calling timeout handler
browserless.io:router:error   Websocket job has timedout, sending 429 response
browserless.io:browser-manager:debug  Closing browser session
browserless.io:server:trace   Websocket connection complete

but the client-side error from the script above was:

Error: Attempted to use detached Frame 'FD20EE86D12C7D30B8D3CFDA4F3B1DA6'.

— nothing that says "timeout," "408," or anything server-controlled at all; Puppeteer's own local bookkeeping just noticed the frame went away. I haven't run a side-by-side comparison against an actual crash yet, but based on the code path, a crash would surface through the same kind of generic disconnect, giving the client no way to tell the two apart either way.

Small side note from the log above: onWebsocketTimeout logs "...sending 429 response" but the code actually sends 408 (writeResponse(socket, 408, 'Request has timed out')). Looks like a copy-paste leftover from onQueueFullWebSocket, which does send 429 — the log message just wasn't updated. Not the main bug, but worth fixing alongside it.

Expected behavior:

The client should be able to tell a server-enforced timeout apart from an unexpected crash — ideally via a real WebSocket close frame with a distinct code/reason before the proxy connection is torn down, rather than a raw HTTP-formatted string written into an active binary stream. That would let client-side retry/error-handling logic branch on "the server timed me out, maybe retry with a longer TIMEOUT" versus "something actually crashed."

Extra details:

  • The intent to signal timeout does exist server-side — this isn't a case of browserless silently dropping the connection with zero effort. Limiter.handleJobTimeout (src/limiter.ts) does call the timeout handler correctly:
    typescript
    protected handleJobTimeout({
      detail: { next, job },
    }: {
      detail: { job: Job; next: Job };
    }) {
      ...
      this.metrics.addTimedout(Date.now() - job.start);
      this.webhooks.callTimeoutAlertURL();
      job?.onTimeoutFn(job);
      ...
    }
    and onWebsocketTimeout in router.ts does call writeResponse(socket, 408, ...). The problem is specifically that writeResponse's Duplex branch (utils.ts) is HTTP-shaped, not WebSocket-close-frame-shaped, and doesn't account for a socket that's already mid-proxy via this.proxy.ws(...) in proxyWebSocket (browsers.cdp.ts).
  • This makes retry logic harder to write correctly on the client side, since "did I hit TIMEOUT" and "did the browser crash" currently look the same over the wire.
  • Happy to attempt a PR for this if a maintainer can confirm the intended direction — e.g. whether the fix should live in writeResponse (branch on whether the target socket has already been handed to http-proxy) or in onWebsocketTimeout itself (send a real socket.close(<code>, 'timeout') at the WS layer instead of going through writeResponse at all for the proxied-session case).