Troubleshooting9 min read

Fix ERR_TUNNEL_CONNECTION_FAILED in Playwright and Puppeteer

ERR_TUNNEL_CONNECTION_FAILED and ERR_PROXY_CONNECTION_FAILED explained with a verified proxy failure matrix, plus how to recover the CONNECT status Chromium hides.

On this page

net::ERR_TUNNEL_CONNECTION_FAILED means Chromium reached your proxy, asked it to open a tunnel to an HTTPS destination with a CONNECT request, and the proxy answered with something other than success. net::ERR_PROXY_CONNECTION_FAILED means Chromium never got that far: it could not establish a connection to the proxy at all. Both codes come from Chromium's network stack, so Playwright, Puppeteer and any other tool driving Chromium report the same strings, usually as page.goto: net::ERR_TUNNEL_CONNECTION_FAILED at https://....

The frustrating part is what Chromium leaves out. A proxy that refuses a tunnel with 403, one that is rate limiting you with 429, and one whose upstream is down with 502 all produce the identical ERR_TUNNEL_CONNECTION_FAILED. This guide shows which proxy behaviour produces which error in Playwright, Puppeteer and curl, reproduced against small controlled proxies rather than collected from forum threads, and then gives you a short procedure to recover the status the browser hides.

Seeing this in Chrome or Edge without any code?

The meaning is the same in a normal browser window. Chrome and Edge share Chromium's network stack, and by default they take their proxy settings from the operating system, so the error tells you that a proxy is configured somewhere and that it refused to open the tunnel (ERR_TUNNEL_CONNECTION_FAILED) or could not be reached at all (ERR_PROXY_CONNECTION_FAILED). The website itself is not down; the browser never got past the proxy. The usual sources of that configuration are a VPN client that installs a system proxy while connected, a proxy or VPN browser extension, a company policy or PAC script on a managed device, or a proxy that was entered by hand and forgotten.

How to fix it, in the order that finds the cause fastest:

  • Open chrome://net-internals/#proxy (or edge://net-internals/#proxy) to see the effective proxy settings the browser is using right now. If you did not expect a proxy there, that is the problem.
  • Disconnect the VPN or disable proxy extensions, reload, and compare. If the page loads, the VPN or extension's proxy was refusing the tunnel or was unreachable.
  • On a work or school device, the proxy administrator's rules decide which sites are allowed. ERR_TUNNEL_CONNECTION_FAILED on an HTTPS site that is blocked by policy is expected behaviour, and only the administrator can change it.
  • If http:// pages load but https:// pages fail, the proxy is refusing CONNECT to port 443. That is the tunnel case in the matrix below, and the proxy's own status code says why.

The rest of this guide is written for developers who configured the proxy themselves in Playwright or Puppeteer, but every error string means exactly the same thing in the address bar.

Two errors, two stages

For an HTTPS destination through an HTTP proxy, Chromium resolves the proxy hostname, opens a TCP connection to it, sends CONNECT host:443, waits for a 2xx reply, and only then starts the destination TLS handshake inside the tunnel. The two error codes mark two different points on that path:

  • ERR_PROXY_CONNECTION_FAILED: the proxy hostname did not resolve, nothing accepted the TCP connection, or the connection to the proxy failed before any HTTP was exchanged. A common self-inflicted variant is writing https:// in front of a gateway that only speaks plain HTTP, which makes Chromium attempt a TLS handshake with a server that expects cleartext.
  • ERR_TUNNEL_CONNECTION_FAILED: the TCP connection worked and the proxy answered the CONNECT, but not with a success status. Chromium discards the status code, the reason phrase and any body the proxy sent.

Everything else you may see (ERR_CONNECTION_RESET, ERR_INVALID_HTTP_RESPONSE, ERR_SOCKS_CONNECTION_FAILED, a plain navigation timeout) belongs to a different stage and is covered further down.

What each proxy failure looks like

The table records what each client reported for one deliberate proxy behaviour. Playwright used its proxy launch option; Puppeteer used raw Chromium flags (--proxy-server) with page.authenticate for credentials; curl used -x with --proxy-user. All three talked to the same Chromium 153 build or curl 8.18 on Linux; details are in the method section.

Proxy behaviourPlaywright 1.63 (Chromium 153)Puppeteer 25 (same Chromium)curl 8.18
Proxy hostname does not resolveERR_PROXY_CONNECTION_FAILEDsame(5) Could not resolve proxy
Nothing listens on the proxy portERR_PROXY_CONNECTION_FAILEDsame(7) Failed to connect
https:// scheme, but the proxy speaks plain HTTPERR_PROXY_CONNECTION_FAILEDsame(35) TLS connect error
Proxy accepts TCP and never answersnavigation timeoutnavigation timeout(28) Connection timed out
Proxy accepts TCP and closes at onceERR_CONNECTION_RESETsame(56) Proxy CONNECT aborted or (56) Recv failure: Connection reset by peer
Port answers, but not with HTTPERR_INVALID_HTTP_RESPONSEsame(56) Proxy CONNECT aborted
CONNECT answered 403ERR_TUNNEL_CONNECTION_FAILEDsame(7) CONNECT tunnel failed, response 403
CONNECT answered 429ERR_TUNNEL_CONNECTION_FAILEDsame(7) CONNECT tunnel failed, response 429
CONNECT answered 502 or 503ERR_TUNNEL_CONNECTION_FAILEDsame(7) CONNECT tunnel failed, response 502 or 503
CONNECT answered 407, no credentials configurednavigation resolves with status 407, then ERR_TUNNEL_CONNECTION_FAILED on the requestERR_INVALID_AUTH_CREDENTIALS(7) CONNECT tunnel failed, response 407
CONNECT answered 407, wrong credentialsnavigation resolves with status 407, then ERR_TUNNEL_CONNECTION_FAILED on the requestnavigation resolves with status 407 and Chromium's error page, then ERR_HTTP_RESPONSE_CODE_FAILURE(7) CONNECT tunnel failed, response 407
Correct credentials200 from the destination200200, http_connect 200
CONNECT answered 200, then the proxy drops the tunnelERR_CONNECTION_CLOSED or ERR_CONNECTION_RESETERR_CONNECTION_RESET(35) Send failure: Broken pipe
socks5:// scheme against an HTTP proxyERR_SOCKS_CONNECTION_FAILEDsame(97) Received invalid version in initial SOCKS5 response
socks5:// with a username and passwordrefused at launch: Browser does not support socks5 proxy authenticationnot exercisednot exercised
Plain http:// destination, proxy rejects with 403 or 407no error: the proxy's own 403 or 407 page is the navigation responsesame403 or 407 with http_connect 000

Three rows deserve a second look. Every non-success CONNECT status from 403 to 503 collapses into one browser error, so the browser alone cannot tell you whether you are blocked, throttled or looking at a broken upstream. A 407 is the only status Chromium handles specially, and Playwright and Puppeteer surface it differently. And a plain http:// destination never produces a tunnel error, because there is no tunnel: the proxy's rejection arrives as an ordinary response with the proxy's status and body, which is why the same script can "work" on an HTTP test page and fail on the HTTPS site you care about.

Recover the CONNECT status with curl

curl keeps the status that Chromium throws away. Run one bounded request against the same gateway, destination and credentials your browser script uses, and print both the destination status and the CONNECT status. The command reads PROXY_URL, PROXY_USERNAME and PROXY_PASSWORD from the environment so the password never appears in a shell argument or history entry; curl 8.3 or later is required for the variable expansion.

sh
: "${PROXY_URL:?Set the gateway as http://host:port (no credentials in the URL)}"
CHECK_URL=${CHECK_URL:-https://example.com/}
curl --disable --silent --show-error --output /dev/null \
  --noproxy '' --proxy "$PROXY_URL" --proxy-basic \
  --variable %PROXY_USERNAME --variable %PROXY_PASSWORD \
  --expand-proxy-user '{{PROXY_USERNAME}}:{{PROXY_PASSWORD}}' \
  --max-time 15 \
  --write-out 'destination=%{http_code} connect=%{http_connect} exit=%{exitcode}\n' \
  "$CHECK_URL"

connect=200 with a destination status means the tunnel works and the browser problem is elsewhere. A non-zero connect value is the status Chromium hid:

  • 407: the proxy wants credentials it did not accept. Check the account or sub-user, percent-encoding, and whether this gateway authenticates by password or by allowed source IP. The 407 guide walks through that order.
  • 403: the proxy accepted the connection but refuses this tunnel. Typical causes are a destination or port outside the provider's allowed list, an account restriction, or a country or session parameter the gateway rejects.
  • 429: concurrency or rate limits at the proxy. Look for a Retry-After header in curl -v output and reduce parallel browsers before retrying.
  • 502, 503, 504: the proxy could not reach or select an upstream exit. Retrying immediately rarely helps; note the time and ask the provider what their gateway logged.

connect=000 with a curl exit code other than 0 means the failure happened before any CONNECT reply, which matches the ERR_PROXY_CONNECTION_FAILED and reset rows above. The timeout guide shows how to time each stage when the failure is slow rather than immediate.

Playwright: a 407 does not throw

With no proxy credentials, or wrong ones, page.goto did not reject in the recorded run. It resolved with a response whose status was 407 and whose statusText was Proxy Authentication Required, with the proxy's Proxy-Authenticate header readable on that response and an empty body; a requestfailed event with ERR_TUNNEL_CONNECTION_FAILED fired for the same navigation. A script that only awaits goto and continues therefore keeps running against an empty page. Check response.ok() and treat a 407 as a proxy problem, not a destination problem:

js
import { chromium } from 'playwright';

function required(name) {
  const value = process.env[name];
  if (!value) throw new Error('Missing environment variable: ' + name);
  return value;
}

const browser = await chromium.launch({
  proxy: {
    server: required('PROXY_URL'),           // http://host:port, no credentials in the URL
    username: required('PROXY_USERNAME'),
    password: required('PROXY_PASSWORD'),
  },
});
try {
  const page = await browser.newPage();
  page.on('requestfailed', (request) => {
    console.error('request failed:', request.failure()?.errorText, request.url());
  });
  const response = await page.goto('https://example.com/', { waitUntil: 'domcontentloaded' });
  if (!response) throw new Error('No navigation response');
  if (response.status() === 407) throw new Error('Proxy rejected the credentials (407)');
  if (!response.ok()) throw new Error('Destination answered ' + response.status());
} finally {
  await browser.close();
}

Put proxy credentials in proxy.username and proxy.password. In the recorded run, credentials supplied through the context's httpCredentials option also satisfied the proxy challenge, because Playwright answers Chromium's authentication request from either source. Do not rely on that: httpCredentials is meant for the destination, and reusing it for the proxy sends a site login to the proxy operator.

Two more Playwright details from the run. Playwright adds <-loopback> to the bypass list itself, so a destination on localhost or 127.0.0.1 goes through the proxy; raw Chromium does the opposite (see the Puppeteer section). And a socks5:// server with a username and password is refused before the browser starts, with Browser does not support socks5 proxy authentication. Chromium has no SOCKS5 authentication support, so the fix is an HTTP gateway with Basic authentication or IP allow-listing, not a different SOCKS setting. The HTTP vs SOCKS5 guide covers what changes between the two.

Puppeteer: authenticate before the first navigation

Puppeteer passes the proxy to Chromium as a flag and answers 407 challenges only after page.authenticate has been called. Without it, the recorded run produced net::ERR_INVALID_AUTH_CREDENTIALS, a code many people never connect with a proxy. With wrong credentials, goto resolved with status 407 and Chromium's built-in error page as the body, plus a requestfailed event carrying ERR_HTTP_RESPONSE_CODE_FAILURE.

js
import puppeteer from 'puppeteer';

function required(name) {
  const value = process.env[name];
  if (!value) throw new Error('Missing environment variable: ' + name);
  return value;
}

const browser = await puppeteer.launch({
  args: [
    '--proxy-server=' + required('PROXY_URL'),   // http://host:port
    '--proxy-bypass-list=<-loopback>',          // only if local destinations must use the proxy
  ],
});
try {
  const page = await browser.newPage();
  await page.authenticate({
    username: required('PROXY_USERNAME'),
    password: required('PROXY_PASSWORD'),
  });
  page.on('requestfailed', (request) => {
    console.error('request failed:', request.failure()?.errorText, request.url());
  });
  const response = await page.goto('https://example.com/', { waitUntil: 'domcontentloaded' });
  if (!response) throw new Error('No navigation response');
  if (response.status() === 407) throw new Error('Proxy rejected the credentials (407)');
  if (!response.ok()) throw new Error('Destination answered ' + response.status());
} finally {
  await browser.close();
}

Two Chromium defaults matter here. First, Chromium bypasses the proxy for localhost, 127.0.0.1 and [::1] unless the bypass list contains <-loopback>. In the run, a Puppeteer script pointed at a proxy that rejected every tunnel still loaded a local HTTPS page with status 200, because the proxy was never consulted. A test that "passes" against a local server proves nothing about the proxy. Second, a raw Chromium launched with --proxy-server routed its own background traffic through the proxy as well: the recorded proxy log shows a GET to clients2.google.com and a CONNECT to update.googleapis.com:443 before the page navigation. Expect those hostnames in provider logs and metered traffic, and do not mistake their failures for your navigation failing. Playwright's launch flags did not produce that traffic in the same run.

Errors that are not tunnel errors

  • ERR_CONNECTION_RESET or ERR_CONNECTION_CLOSED right after connecting: the proxy accepted the TCP connection and dropped it, either before answering CONNECT or immediately after a 200. Providers do this when an exit is unavailable or the connection is being rate limited at the socket level. curl reports Proxy CONNECT aborted or Send failure: Broken pipe for the same behaviour.
  • ERR_INVALID_HTTP_RESPONSE: something answered on that port, but not in HTTP. Usually the port number belongs to a SOCKS listener, a TLS-only listener or a different service.
  • ERR_SOCKS_CONNECTION_FAILED: you configured socks5:// and the server spoke HTTP. Swap the scheme to match the gateway's documented protocol before changing anything else.
  • A navigation timeout with no net error: the proxy accepted the connection and never replied. Raising the timeout will not help; verify the port and protocol with curl, which times out with (28) in the same situation.
  • ERR_PROXY_CONNECTION_FAILED on a gateway that works in curl: compare the scheme. Chromium attempts TLS for https:// proxies, and most gateways are plain http:// endpoints that carry HTTPS destinations inside the tunnel.

A diagnostic order

  1. Reproduce with curl using the same gateway, destination and credentials, and read http_connect. This converts the browser's one-word error into the proxy's actual status in under a minute.
  2. Confirm the proxy scheme (http://, https:// or socks5://) against the provider's current instructions. Two of the three ERR_PROXY_CONNECTION_FAILED rows above are scheme mistakes.
  3. Put credentials where the tool expects them: proxy.username and proxy.password in Playwright, page.authenticate before the first navigation in Puppeteer. Check response.status() === 407 explicitly in both.
  4. Test an HTTPS destination, not an HTTP one. HTTP destinations skip CONNECT and hide tunnel problems behind ordinary proxy responses.
  5. Check bypass rules. Local and internal destinations bypass the proxy in raw Chromium, and corporate or environment proxy settings can override what you passed.
  6. Report with the client versions, UTC time, gateway host and port, destination hostname and the curl http_connect value. Leave passwords, credential-bearing URLs and raw traces out; the 407 guide has a sanitised report template.

Method and downloads

ipvolt ran the reproduction on 17 September 2026 on a Linux host with Node.js 24.20.0, Playwright 1.63.0 (Chromium 153.0.8010.12, headless), puppeteer-core 25.11.0 driving the same Chromium build, and curl 8.18.0. Each "proxy" was a small Node.js server on 127.0.0.1 with exactly one behaviour; the destination was a local HTTPS server with a throwaway self-signed certificate, so the browser cases ignored destination certificate errors, which does not affect how Chromium talks to the proxy. Firefox and WebKit were not exercised, and no commercial gateway was involved: the table describes Chromium's reaction to a proxy behaviour, not any provider's policy.

The lab archive contains the lab script, the package manifest, the README and the recorded results, including the proxy-side log that shows whether each client reached the proxy and whether credentials arrived. It runs on loopback only and needs no proxy account.

If you want to hear when ipvolt access opens, join the early-access list. One email when access opens. Nothing else. These examples are client diagnostics, not documentation of an available ipvolt endpoint.

Sources & further reading

Technical references used for this guide. Check the documentation for your installed version and your provider’s supported configuration.