๐ŸŽ‰ Premium Proxies ยท 24-Hour Free TrialClaim Now
Guides

Fix net::ERR_TUNNEL_CONNECTION_FAILED in Puppeteer

net::ERR_TUNNEL_CONNECTION_FAILED in Puppeteer or Playwright? Map it to one of four causes, reproduce each with curl, then use launch code that works.

S SparkProxy 4 18 min read
Share

net::ERR_TUNNEL_CONNECTION_FAILED in Puppeteer (or Playwright) means headless Chromium reached your proxy, asked it to open a tunnel to the target site, and got an answer other than "yes". The script ran fine without the proxy, so the instinct is to blame the credentials. Sometimes that is right. Often it isn't, and the four usual suspects each leave a different fingerprint you can check in under a minute with curl.

What page.goto Throws

In Puppeteer the navigation promise rejects with this message, and nothing else:

Error: net::ERR_TUNNEL_CONNECTION_FAILED at https://www.sparkproxy.io/

Playwright prefixes the method name:

page.goto: net::ERR_TUNNEL_CONNECTION_FAILED at https://www.sparkproxy.io/

Two details in that string matter. First, the URL is https://. This error only exists for HTTPS targets, because only HTTPS traffic goes through a CONNECT tunnel. Point the same proxy at a plain http:// page and Chromium sends an ordinary forwarded request instead, so a broken proxy shows up as a status code on the page rather than as an exception. Second, the message carries no status code. The proxy did send one, but Chromium throws it away, which is why this error feels so opaque. You can get it back, and the NetLog section shows how.


What Chromium Means by a Failed Tunnel

Chromium's error list (net/base/net_error_list.h, read on the main branch on 2026-10-30) defines code -111 in one line: "A tunnel connection through the proxy could not be established."

The useful part is in net/http/http_proxy_client_socket.cc, in the function that handles the proxy's reply to CONNECT. It has three branches:

  • 200: the tunnel is open and TLS to the target starts. (If the proxy sends extra bytes after the 200 headers, that also returns ERR_TUNNEL_CONNECTION_FAILED.)
  • 407: Chromium starts the proxy-authentication flow.
  • Anything else: return ERR_TUNNEL_CONNECTION_FAILED. The source comment explains that Chromium ignores the body "to avoid letting the proxy impersonate the target server," and admits the cost: "We have seen proxy 403, 404, and 501 response bodies that contain a useful error message."

So the precise meaning is: the proxy answered your CONNECT with a status that was neither 200 nor 407. A 403, 404, 502, 503 or 504 all collapse into the same string. If you want the general background on how the tunnel itself works, our explainer on what an HTTP tunnel is covers the handshake.


Free trial

Scraping at scale? Skip the blocks.

Fast, unblockable datacentre proxies with unlimited bandwidth.

Measured: Which Mistake Produces Which Error

Forum answers usually list four causes for this error: a 407 rejection, an unreachable upstream, a wrong scheme in --proxy-server, and calling page.authenticate() too late. We reproduced each one on 2026-10-30 to see what headless Chromium actually throws.

Setup: Google Chrome 156.0.8078.74 (installed build) on Windows Server 2022, driven by puppeteer-core 25.16.0 (published to npm 2026-10-22) and playwright-core 1.64.1 (published 2026-10-14). Small local Node proxies simulated each proxy-side reply; the scheme rows also ran against the real gateway.sparkproxy.io ports. One run per case, so treat timings as rough.

What was wrongPuppeteer 25.16.0Playwright 1.64.1
Proxy answers CONNECT with 502 (target unreachable)`ERR_TUNNEL_CONNECTION_FAILED``ERR_TUNNEL_CONNECTION_FAILED`
Proxy answers CONNECT with 403`ERR_TUNNEL_CONNECTION_FAILED``ERR_TUNNEL_CONNECTION_FAILED`
Target hostname does not resolve at the proxy (proxy replies 502)`ERR_TUNNEL_CONNECTION_FAILED`not run
Proxy sends 407, no credentials supplied`ERR_INVALID_AUTH_CREDENTIALS``ERR_INVALID_AUTH_CREDENTIALS`
Proxy sends 407, wrong password`goto` resolves, `response.status()` is 407`ERR_HTTP_RESPONSE_CODE_FAILURE`
Proxy sends 407 without a `Proxy-Authenticate` header`ERR_PROXY_AUTH_UNSUPPORTED``ERR_PROXY_AUTH_UNSUPPORTED`
`page.authenticate()` called after the first `goto()`first `goto`: `ERR_INVALID_AUTH_CREDENTIALS`; retry worksn/a (credentials go in `launch`)
`user:pass@` inside `--proxy-server``ERR_NO_SUPPORTED_PROXIES`n/a
`https://` scheme on a plain HTTP proxy port`ERR_PROXY_CONNECTION_FAILED``ERR_PROXY_CONNECTION_FAILED`
`socks5://` scheme on an HTTP proxy port`ERR_SOCKS_CONNECTION_FAILED`not run
`http://` scheme on the SOCKS5 port 13000`ERR_EMPTY_RESPONSE`not run
Nothing listening on the proxy port`ERR_PROXY_CONNECTION_FAILED``ERR_PROXY_CONNECTION_FAILED`
SOCKS5 server with `username`/`password`n/alaunch fails: `Browser does not support socks5 proxy authentication`

The result changes the diagnosis. In current Chromium, the exact string ERR_TUNNEL_CONNECTION_FAILED came only from a CONNECT refused with a non-407 status. A genuine 407 surfaced as ERR_INVALID_AUTH_CREDENTIALS or as a 407 page, and the scheme and late-authenticate mistakes produced neighbouring errors. That still makes all four causes worth checking, for two reasons: many scrapers match on substrings like TUNNEL or PROXY and log the wrong one, and some proxies answer bad credentials or an unlisted IP with 403 instead of 407, which turns an auth problem into this exact error.


One curl Command Splits the Problem

Work down this list from the top; none of the steps takes more than a few seconds, and each one rules out a whole branch.

  1. Is the target https://? If you see this error on a plain http:// URL, re-read your stack trace; something else is wrong.
  2. Does curl get a 200 from CONNECT with the same host, port and credentials? This one command splits the problem in half.
  3. If curl gets a non-200, which code? 407 points to credentials or IP allow-listing, 403 to policy, 502/503/504 to the target or the proxy's upstream.
  4. If curl gets a 200 but the browser fails, the bug is in your launch code: scheme, credential placement, or call order.

The curl check that drives all of this prints the CONNECT status separately from the page status:

curl -sS -o /dev/null \
  -w "connect=%{http_connect} page=%{http_code}\n" \
  -x http://USERNAME:PASSWORD@gateway.sparkproxy.io:11000 \
  https://www.sparkproxy.io/

A healthy proxy prints connect=200 page=200. Here is what each failure printed in our runs with curl 8.15.0 on Windows:

Causecurl output (verbatim)
Missing or wrong credentials`curl: (56) CONNECT tunnel failed, response 407`
Proxy refuses by policy`curl: (56) CONNECT tunnel failed, response 403`
Proxy cannot reach the target`curl: (56) CONNECT tunnel failed, response 502`
`http://` on the SOCKS5 port`curl: (56) Proxy CONNECT aborted`
`socks5h://` on the HTTP port`curl: (97) connection to proxy closed`
`https://` on a plain HTTP port`curl: (35) schannel: failed to receive handshake, SSL/TLS connection failed`
Proxy hostname does not resolve`curl: (5) Could not resolve proxy: gate.sparkproxy.io`

The https:// row is specific to the Schannel build of curl that ships with Windows; an OpenSSL build words the TLS failure differently. The exit codes are stable across builds.


Cause 1: The Proxy Refuses the CONNECT

Symptom in the browser: ERR_TUNNEL_CONNECTION_FAILED when the proxy refuses with 403, or ERR_INVALID_AUTH_CREDENTIALS / a 407 page when it refuses with 407.

Reproduce: run the curl check above with your real credentials. response 407 means the proxy did not accept what you sent. response 403 means it understood you and still said no.

Common reasons for a refused CONNECT:

  • The username or password is wrong, or contains characters like @, : or # that need percent-encoding in a URL (curl needs encoding; Puppeteer's page.authenticate() and Playwright's proxy.password take the raw value).
  • Your account uses IP allow-listing and the machine running Chromium has a different public IP from the one you registered. Cloud runners and CI jobs change IP often. IP whitelisting for proxies explains the trade-off against user:pass.
  • The plan expired or the trial ended.
  • The proxy blocks the destination port. Tunnels to 443 are normal; tunnels to 25 or unusual ports are often refused.

Fix: correct the credentials or the allow-list entry, then confirm connect=200 in curl before touching the script. SparkProxy accepts both IP whitelist and user:pass auth on the same gateway, so a CI job whose IP keeps moving can switch to user:pass instead of chasing allow-list entries. The full 407 handshake is covered in how proxy authentication works.


Cause 2: The Proxy Cannot Reach the Target

Symptom: ERR_TUNNEL_CONNECTION_FAILED on some URLs or some attempts, while other sites load fine through the same proxy.

Reproduce:

# Same proxy, two targets. If the first works and the second fails, the proxy is fine.
curl -sS -o /dev/null -w "connect=%{http_connect}\n" -x http://USERNAME:PASSWORD@gateway.sparkproxy.io:11000 https://www.sparkproxy.io/
curl -sS -o /dev/null -w "connect=%{http_connect}\n" -x http://USERNAME:PASSWORD@gateway.sparkproxy.io:11000 https://no-such-host.sparkproxy.io/

When the proxy accepts you but cannot open a TCP connection to the target, it answers CONNECT with a 5xx. In our lab, a dead hostname behind an otherwise working proxy produced CONNECT tunnel failed, response 502 in curl and ERR_TUNNEL_CONNECTION_FAILED in Puppeteer. Typical triggers:

  • A typo in the target hostname, or a site that is actually down.
  • The target blocks the exit IP at the TCP level, so the proxy's connection is refused or times out (proxies report this as 502 or 504).
  • A rotating residential exit that went offline mid-request.

Fix: if every target fails, go back to Cause 1. If one target fails, test it without a proxy from a normal browser. If it fails only through the proxy and only sometimes, retry on a fresh IP. With SparkProxy's Random Rotation on port 11000, each new connection can exit from a different IP, so a retry is the right first response. On Sticky Sessions (port 11002) the same exit IP is kept for five minutes, so a bad exit stays bad until the session rolls over; send retries for that job through 11000 instead.


Cause 3: The Wrong Scheme in --proxy-server

Symptom: the error appears on the very first request, every time. In our runs a scheme mistake never produced the exact tunnel string; it produced one of its neighbours, which is the giveaway.

The scheme in --proxy-server describes how Chromium talks to the proxy, not what the target uses. That trips people up in three ways:

  • https://gateway.sparkproxy.io:11000 tells Chromium to open TLS to the proxy itself. Port 11000 speaks plain HTTP, so the handshake fails: ERR_PROXY_CONNECTION_FAILED. Use http:// for an HTTP proxy even when every target is HTTPS.
  • socks5:// on an HTTP port gave ERR_SOCKS_CONNECTION_FAILED, and took almost 15 seconds against the real gateway before failing.
  • http:// on SOCKS5 port 13000 gave ERR_EMPTY_RESPONSE.

The fourth variant is credentials in the flag: --proxy-server=http://user:pass@gateway.sparkproxy.io:11000 returned ERR_NO_SUPPORTED_PROXIES in Chrome 156. Chromium will not parse userinfo there.

Reproduce: curl with the same scheme you put in the flag (-x https://... or -x socks5h://...). The curl table above lists what each mismatch prints. If you are unsure which protocol a port speaks, SOCKS5 vs HTTP proxies lays out the differences.

Fix: match scheme to port. For SparkProxy that is http://gateway.sparkproxy.io:11000 (rotating HTTP/HTTPS), http://gateway.sparkproxy.io:11002 (Sticky Sessions) and socks5://gateway.sparkproxy.io:13000 (SOCKS5). Chromium cannot send SOCKS5 credentials, so SOCKS5 from a headless browser needs IP whitelisting.


Cause 4: page.authenticate() Called After goto()

Symptom: ERR_INVALID_AUTH_CREDENTIALS on the first navigation, then later navigations in the same page work. Curl with the same credentials returns connect=200.

Puppeteer answers proxy challenges through the DevTools Fetch domain. Its NetworkManager (read in puppeteer-core 25.16.0) responds to each authRequired event with ProvideCredentials only if credentials are already stored; with none stored it sends Default, and on a second challenge for the same request it sends CancelAuth. So the order of calls decides everything:

// Broken: the first request is challenged before credentials exist
const page = await browser.newPage();
await page.goto('https://www.sparkproxy.io/'); // throws ERR_INVALID_AUTH_CREDENTIALS
await page.authenticate({ username: 'USERNAME', password: 'PASSWORD' });

That CancelAuth branch also explains a confusing result from the matrix. With a wrong password, Puppeteer tries once, gets challenged again, cancels, and Chromium hands back the proxy's 407 response as if it were the page. page.goto() resolves, response.status() is 407, and nothing throws. Check the status on every navigation, or a bad password looks like an empty scrape.

Fix: call page.authenticate() on each new page before its first goto(). The Puppeteer docs note that "Request interception will be turned on behind the scenes to implement authentication," so expect it to show up if you also use setRequestInterception. The broader setup (launch args, per-page credentials, rotation) is in our guide to using proxies with Puppeteer; this post only covers the failure path.


Reading the Hidden Status Code with NetLog

Because Chromium discards the proxy's reply, the browser error alone cannot tell a 403 from a 502. Chromium's NetLog keeps it. Launch with two extra flags:

const browser = await puppeteer.launch({
  headless: true,
  args: [
    '--proxy-server=http://gateway.sparkproxy.io:11000',
    '--log-net-log=./netlog.json',
    '--net-log-capture-mode=Default',
  ],
});

Reproduce the failure, close the browser so the file is flushed, then pull out the tunnel reply:

// read-tunnel-status.js: prints every CONNECT reply Chromium received
const log = require('./netlog.json');
const id = log.constants.logEventTypes.HTTP_TRANSACTION_READ_TUNNEL_RESPONSE_HEADERS;
for (const ev of log.events) {
  if (ev.type === id) console.log(ev.params.headers.join(' | '));
}

Against a proxy that refused with 403, this printed HTTP/1.1 403 Forbidden | Content-Length: 0. Now you know which cause you have without guessing. The same trick works in Playwright by passing the flags through args in chromium.launch(). NetLog files can contain URLs and cookies, so don't paste them into public issue trackers unredacted.


Puppeteer Launch Code That Works

This version folds every fix above into one script: plain http:// scheme, credentials via page.authenticate() before navigation, an explicit status check, and an error classifier so your logs name the real cause.

import puppeteer from 'puppeteer';

const PROXY = 'http://gateway.sparkproxy.io:11000';
const CREDS = { username: process.env.SPARK_USER, password: process.env.SPARK_PASS };

function classify(err) {
  const m = String(err.message);
  if (m.includes('ERR_TUNNEL_CONNECTION_FAILED')) return 'proxy refused CONNECT (non-407): check curl connect code';
  if (m.includes('ERR_INVALID_AUTH_CREDENTIALS')) return 'no credentials at challenge time: authenticate() order or IP allow-list';
  if (m.includes('ERR_NO_SUPPORTED_PROXIES')) return 'credentials or bad syntax inside --proxy-server';
  if (m.includes('ERR_PROXY_CONNECTION_FAILED')) return 'proxy host/port unreachable or https:// scheme on an HTTP port';
  if (m.includes('ERR_SOCKS_CONNECTION_FAILED')) return 'socks5:// pointed at a non-SOCKS port';
  if (m.includes('ERR_PROXY_AUTH_UNSUPPORTED')) return 'proxy sent 407 without a usable Proxy-Authenticate header';
  return 'not a proxy-layer error';
}

const browser = await puppeteer.launch({ headless: true, args: [`--proxy-server=${PROXY}`] });
try {
  const page = await browser.newPage();
  await page.authenticate(CREDS); // before the first goto, on every new page
  const res = await page.goto('https://www.sparkproxy.io/', { waitUntil: 'domcontentloaded', timeout: 30000 });
  if (res.status() === 407) throw new Error('Proxy rejected credentials (407 page)');
  console.log('status', res.status());
} catch (err) {
  console.error(classify(err), '|', err.message);
} finally {
  await browser.close();
}

If you need several proxies inside one browser, Puppeteer supports a per-context proxy server; the Puppeteer guide linked above walks through it.


Playwright Launch Code That Works

Playwright takes credentials in the proxy object, so the late-authenticate mistake cannot happen. The scheme mistake still can. Our run also showed that a bare host:port without a scheme works, since Playwright treats it as HTTP.

import { chromium } from 'playwright';

const browser = await chromium.launch({
  proxy: {
    server: 'http://gateway.sparkproxy.io:11000',
    username: process.env.SPARK_USER,
    password: process.env.SPARK_PASS,
  },
});
const page = await browser.newPage();
const res = await page.goto('https://www.sparkproxy.io/', { waitUntil: 'domcontentloaded' });
console.log(res.status());
await browser.close();

The same thing in Python, with the exception mapped to a cause:

import os
from playwright.sync_api import sync_playwright, Error

PROXY = {
    "server": "http://gateway.sparkproxy.io:11000",
    "username": os.environ["SPARK_USER"],
    "password": os.environ["SPARK_PASS"],
}

with sync_playwright() as p:
    browser = p.chromium.launch(proxy=PROXY)
    page = browser.new_page()
    try:
        res = page.goto("https://www.sparkproxy.io/", wait_until="domcontentloaded")
        print("status", res.status if res else None)
    except Error as exc:
        msg = str(exc)
        if "ERR_TUNNEL_CONNECTION_FAILED" in msg:
            print("Proxy refused CONNECT with a non-407 status; run the curl check")
        elif "ERR_HTTP_RESPONSE_CODE_FAILURE" in msg:
            print("Proxy kept answering 407: wrong password or plan state")
        else:
            raise
    finally:
        browser.close()

For SOCKS5, drop username and password and allow-list your IP; Playwright 1.64.1 refuses to launch with SOCKS5 credentials. Context-level proxies and rotation patterns are covered in our Playwright proxy setup guide.


Errors a Different Proxy Will Not Fix

Swapping providers is the most common reaction to this error and often the wrong one. Sort the cause first:

CauseFixed by changing proxy?What actually fixes it
Wrong scheme in `--proxy-server` or `server`NoMatch the scheme to the port
Credentials in the flagNo`page.authenticate()` or the `proxy` object
`authenticate()` after `goto()`NoReorder the calls
Target site down or hostname typoNoFix the URL or wait
Wrong credentials, unlisted IP, expired planNo, but your account settings willUpdate credentials or the allow-list
Exit IP blocked by the target at TCP levelYes, a fresh exit IP usually doesRetry on a rotating port
Proxy upstream intermittently failingYesRetry; escalate to the provider if it persists

Five of the seven rows go away without changing provider at all. A different proxy can only help with the last two. Once the tunnel opens and pages start coming back as CAPTCHAs or 403s from the site itself, that is detection, not connectivity, and a broader map of status codes is in proxy error codes explained.

If you would rather not run proxies inside the browser at all, the SparkProxy Scraping API takes the target URL and handles the proxy and the headless render server-side. render_js defaults to true (5 credits per request), and new accounts get 1,000 free credits:

curl -G "https://scrape.sparkproxy.io/api/v1" \
  -H "X-API-Key: YOUR_API_KEY" \
  --data-urlencode "url=https://www.sparkproxy.io/" \
  -d "render_js=true"

For proxies in your own Puppeteer or Playwright fleet, SparkProxy plans start from $75/month (USD, as listed on sparkproxy.io in October 2026) with unlimited bandwidth, so retries after a failed tunnel don't add a traffic bill. The pool is 1M+ premium IPs across 80+ countries over HTTP/HTTPS/SOCKS5, and there is a 24-hour free trial with no credit card, which is enough time to run every check in this post against a real gateway.


Frequently asked questions

FAQ

It means the proxy answered Chromium's CONNECT request with a status other than 200 or 407, typically 403, 502, 503 or 504. Chromium discards the proxy's reply, so the status code is not in the error message; curl with -w "%{http_connect}" or a NetLog capture shows it.

If curl gets connect=200 with the same host, port and credentials, the proxy is fine and the problem is in your launch code. Check for an https:// scheme on an HTTP port, credentials inside --proxy-server, or page.authenticate() running after the first goto().

Not in our Chrome 156 tests. A 407 with no credentials produced ERR_INVALID_AUTH_CREDENTIALS, and a wrong password produced a 407 page in Puppeteer and ERR_HTTP_RESPONSE_CODE_FAILURE in Playwright. A proxy that rejects bad credentials with 403 instead of 407 does produce the tunnel error.

Run the curl CONNECT check first. A 403 or 5xx means the proxy or the target is refusing; a 200 means your proxy.server scheme or port is wrong. Playwright takes credentials in the proxy object, so call order is never the cause there.

No. Chrome 156 rejected http://user:pass@host:port in --proxy-server with ERR_NO_SUPPORTED_PROXIES. Pass the bare host and port in the flag and supply credentials with page.authenticate() in Puppeteer or the proxy object in Playwright.

Retry once or twice on a rotating port if the CONNECT reply was 502, 503 or 504, because a fresh exit IP often gets through. Do not retry a 403 or a 407 in a loop; those are configuration or account problems and repeated attempts only add noise.


Special Discount ยท 20% off

Get 20% off your first month

Premium datacentre proxies with unlimited bandwidth. Use the code at checkout.

Save up to 15% more on quarterly, half-yearly and yearly plans

Claim Discount

About the Author

The SparkProxy Technical Team builds and operates SparkProxy's datacenter and residential proxy network and the SparkProxy Scraping API. Every error string and result in this post was reproduced on our own test machine on 2026-10-30 with the versions named above, and the Chromium behaviour is quoted from its source code rather than paraphrased from forums.

Sources (read 2026-10-30): Chromium net/base/net_error_list.h and net/http/http_proxy_client_socket.cc (chromium.googlesource.com, main branch); Puppeteer page.authenticate() reference (pptr.dev); Playwright network and proxy docs (playwright.dev/docs/network); npm registry release dates for puppeteer 25.16.0 and playwright 1.64.1; SparkProxy Scraping API docs (sparkproxy.io/docs/scraping-api).

Keep reading

Related articles