Playwright ERR_TUNNEL_CONNECTION_FAILED: A Reproducible Checklist

The Chromium error places the failure before a usable proxy tunnel formed. This checklist helps you reproduce the boundary without guessing at the cause.

Open the tunnel checklist

ERR_TUNNEL_CONNECTION_FAILED is narrower than a generic browser failure and much less conclusive than it sounds. Chromium could not establish a usable tunnel through the configured proxy. It has not told you that the provider is down, the password is wrong, or the destination blocked the browser.

Treat the error as a boundary marker: the tunnel did not become usable. The reason is still open.

What Chromium actually reported

Chromium currently defines network error -111 in its network error list (opens in a new tab) as a tunnel connection through the proxy that could not be established. For HTTPS through an HTTP proxy, the client first sends CONNECT host:port. RFC 9110 (opens in a new tab) says a successful 2xx CONNECT response switches that connection into tunnel mode. A non-2xx response means the tunnel was not formed.

The current Chromium HTTP/1 proxy-tunnel implementation (opens in a new tab) returns this generic error for a response version below HTTP/1.0, extra buffered bytes after a 200, and any CONNECT status other than 200 or 407. That is an implementation detail worth recording: the RFC permits any 2xx while this Chromium path currently accepts 200. Save both the Playwright version and browser.version() with the reproduction.

Work the checklist before changing the route

The worksheet below accepts only bounded observations. It does not ask for credentials, hostnames, target URLs, IP addresses, or session identifiers, and it does not make a network request. Start with what the clients recorded and change one input at a time.

Place the tunnel failure, one control at a time.

Six bounded observations narrow the next capture. This worksheet does not make a network request.

Nothing leaves this page. The checklist accepts no credentials, hostnames, URLs, IP addresses, headers, or session identifiers.

Record a control before naming a failing layer. Strongest supported layer: Evidence collection. Evidence: insufficient evidence.

Evidence collectioninsufficient evidence

Record a control before naming a failing layer.

No request scope, response event, configuration review, or control result is recorded.

This does not prove
  • That the proxy, browser, runner, or destination caused the failure.
  • That an HTTP response was absent. It may simply be missing from the capture.
Capture next
  • Identify the failed request as the main navigation or a subresource.
  • Record whether that request emitted a response event or only requestfailed.
  • Run the direct controlled-page baseline before changing proxy settings.

Build one reproducible comparison

1. Capture the failed request

Record whether the failure belonged to the main navigation or a subresource. Save the page.goto() error token, request.failure()?.errorText, whether a navigation response event appeared, and the browser version. Start with the event sequence, not a screenshot.

2. Freeze one test

Use one controlled or explicitly authorized URL, one proxy endpoint, one runner, and Playwright's bundled Chromium. Start a fresh browser context, set retries to zero, and keep the deadline fixed. Do not swap the endpoint, browser channel, proxy scheme, and timeout in the same run.

3. Review the proxy configuration

For this checklist, use an explicit HTTP or HTTPS proxy URL. Playwright's proxy.username and proxy.password fields apply to HTTP proxy authentication. httpCredentials configures server-side HTTP authentication, not proxy authentication. Record whether the proxy is launch-wide or context-specific and whether a bypass rule can match the test host. The current options are documented on BrowserType.launch (opens in a new tab). Diagnose SOCKS failures separately.

4. Repeat from the same runner with cURL

Use the same proxy product, configured endpoint, protocol, and controlled HTTPS destination. Capture the cURL exit code and error text beside http_connect, response_code, remote address, and timings. Matching those client-side inputs still does not prove that cURL and Chromium used the same provider backend, DNS resolver, session, or egress route.

Four controls, each answering one smaller question
RouteDestinationWhat the row establishes
DirectControlled HTTPS pageWhether the runner and browser have a healthy baseline without the application proxy
ProxyControlled HTTPS pageWhether the configured proxy route can form CONNECT away from the reported target
ProxyAuthorized reported URLWhether the original condition reproduces after the controlled route is understood
HTTP(S) proxy onlyControlled HTTP page, optionalHow ordinary HTTP forwarding behaves without a CONNECT tunnel
Four controls, each answering one smaller question
Route
Direct
Destination
Controlled HTTPS page
What the row establishes
Whether the runner and browser have a healthy baseline without the application proxy
Route
Proxy
Destination
Controlled HTTPS page
What the row establishes
Whether the configured proxy route can form CONNECT away from the reported target
Route
Proxy
Destination
Authorized reported URL
What the row establishes
Whether the original condition reproduces after the controlled route is understood
Route
HTTP(S) proxy only
Destination
Controlled HTTP page, optional
What the row establishes
How ordinary HTTP forwarding behaves without a CONNECT tunnel

Capture both clients without printing secrets

A small, repeatable evidence packet
LOCAL ONLY
import { createRequire } from 'node:module';
import { chromium } from 'playwright';

const require = createRequire(import.meta.url);
const playwrightVersion = require('playwright/package.json').version;
const target = process.env.TEST_URL;
const server = process.env.PROXY_SERVER;
const username = process.env.PROXY_USERNAME;
const password = process.env.PROXY_PASSWORD;

if (!target || !server) throw new Error('Set TEST_URL and PROXY_SERVER');
if (Boolean(username) !== Boolean(password)) {
  throw new Error('Set both proxy credential fields, or neither');
}

const browser = await chromium.launch({
  proxy: { server, ...(username ? { username, password } : {}) }
});
const context = await browser.newContext();
const page = await context.newPage();

page.on('response', response => {
  if (!response.request().isNavigationRequest()) return;
  console.log({ event: 'response', status: response.status() });
});

page.on('requestfailed', request => {
  if (!request.isNavigationRequest()) return;
  console.log({ event: 'requestfailed', error: request.failure()?.errorText });
});

try {
  const response = await page.goto(target, {
    waitUntil: 'domcontentloaded', timeout: 30_000
  });
  console.log({
    event: 'result', playwrightVersion,
    browserVersion: browser.version(), status: response?.status()
  });
} catch (error) {
  const message = error instanceof Error ? error.message : String(error);
  console.log({
    event: 'navigation-error', playwrightVersion,
    browserVersion: browser.version(),
    netError: message.match(/net::ERR_[A-Z_]+/)?.[0] ?? 'unclassified'
  });
} finally {
  await browser.close();
}

The cURL write-out fields exitcode and errormsg require cURL 7.75 or newer. Client errors such as name resolution failure, connection failure, timeout, TLS failure, and proxy handshake failure are defined in the libcurl error list (opens in a new tab). Each result describes what the client observed; none names the party at fault by itself.

Escalate the capture, not the traffic

If you operate the proxy, correlate the attempt with its UTC timestamp and a nonsecret request identifier. If a provider operates it, send that same narrow packet to support. Preserve only the Proxy-Authenticate scheme and sanitized Proxy-Status fields (opens in a new tab) you are allowed to share. Never copy Proxy-Authorization, cookies, credentials, or a private URL into the ticket.

A local Playwright trace can preserve the browser sequence. If CONNECT remains opaque, Chromium's NetLog guidance (opens in a new tab) explains a deeper capture. Both formats can contain sensitive URLs, headers, tokens, cookies, network configuration, and authentication material. Inspect and sanitize them before sharing.

A support packet someone can investigate

  • UTC timestamp and a nonsecret run ID;
  • Playwright, bundled-browser, cURL, operating-system, and runner-region versions;
  • main navigation or subresource, plus whether a response event appeared;
  • proxy scheme, redacted endpoint alias, and whether authentication was configured;
  • cURL exit code, CONNECT status, transfer status, sanitized remote-address evidence, and timings;
  • direct, controlled-proxy, and authorized-target control results; and
  • the first layer where the controls diverged, plus what remains unknown.

For response placement, use 403 vs. 407 vs. 429 vs. Timeout. If cURL passes while Playwright fails later, use the four-cell diagnostic. If the only signal is a deadline, open the latency worksheet.

Provider claims belong beside current rankings, underlying reports, and the public methodology. Keep each control inside the site's responsible-use boundary.

From the field notes

Continue this diagnostic.

Carry the same standard into provider research

Compare the evidence currently available for each proxy, then inspect the rules behind every public result.