DEV Community

Flora
Flora

Posted on

Your Proxy Tunnels TCP, So That "HTTP/3" Run Was Actually HTTP/1.1

I spent a wasted afternoon blaming a proxy for being slow. The job was a headless fetch loop through a residential gateway, and the numbers bothered me: running the same client straight out to the origin looked fast, running it through the proxy looked like I'd dropped a hundred milliseconds a request and started getting the quirks of an old connection. My mental model was "the proxy is throttling me." It wasn't. I'd asked the client for HTTP/3, and somewhere between my laptop and the origin the request had quietly become HTTP/1.1 over a TCP tunnel. The proxy didn't make the connection slow. It made the connection a different protocol, and I never checked which one actually left the machine.

This post is the short version of how I verify that, because it's a failure mode that bites almost anyone who benches scraping clients and doesn't think about transport family. The headline rule: an HTTP/HTTPS forward proxy carries HTTP/1.1 and HTTP/2, and it cannot carry HTTP/3 at all ??because HTTP/3 is QUIC, which is UDP, and the way a forward proxy gives you a private path to an origin is a CONNECT to a TCP socket. There is no datagram pipe to carry QUIC over. So when you point an --http3 client at an ordinary forward proxy, "HTTP/3" is a label, not a fact, and the honest answer to "what protocol did this request use" is usually "whichever TCP-based one survived."

Why the tunnel is TCP, in one line of verbose output

You don't need to take my word for it. Ask curl to show you the handshake while routing through a forward proxy:

curl -v -o /dev/null -x http://127.0.0.1:8080 https://www.example.com/
Enter fullscreen mode Exit fullscreen mode

The interesting line is the one that talks about the proxy, not the site:

* Connected to 127.0.0.1 (127.0.0.1) port 8080
> CONNECT www.example.com:443 HTTP/1.1
Enter fullscreen mode Exit fullscreen mode

CONNECT is an HTTP/1.1 method. It opens a TCP connection to the proxy and asks the proxy to open a TCP connection to host:443 and stitch the two byte streams together. After that the client runs its own TLS inside that TCP pipe. Everything the client and origin exchange afterward ??whether it eventually speaks HTTP/1.1 or multiplexed HTTP/2 frames ??is riding a TCP stream the proxy is relaying octet by octet. QUIC never had a chance: it wants to send UDP datagrams to port 443 and do its own reliable-ordered-stream thing on top, and there is no UDP in a CONNECT.

There's a second, sneakier gate even before the proxy enters the picture: whether your client build can do HTTP/3 at all. I ran the capability check on the machine I was benching from, and it was worse than I expected ??the build couldn't do HTTP/2 either:

curl --version
# curl 8.17.0 (x86_64-w64-mingw32) libcurl/8.17.0 Schannel zlib/1.3.1 ...
# (no HTTP2, no HTTP3, no ngtcp2/quiche in the feature list)

curl -s -o /dev/null -w 'http_version=%{http_version}\n' https://www.cloudflare.com/
# http_version=1.1        <-- even direct, this client is stuck on h1.1

curl -s -o /dev/null -w '%{http_version}\n' --http2 https://www.cloudflare.com/
# curl: option --http2: the installed libcurl version does not support this
Enter fullscreen mode Exit fullscreen mode

And here's the part that fooled me for a while: --http3 and --http2 show up in curl --help on this exact binary, because the option parser ships with curl. The transport just isn't compiled into the SSL/QUIC backend (this is a Schannel/Windows build). A flag being accepted as an argument is not the same as the transport existing. So the very first thing you should check isn't "did I pass --http3", it's "what does my client report it negotiated."

The origin offering h3 doesn't mean you can use it

The other half of the illusion is the server. Most big endpoints happily advertise HTTP/3 availability, so a quick header check makes you feel like h3 is on the menu:

curl -sI https://www.cloudflare.com/ | grep -i '^alt-svc:'
# alt-svc: h3=":443"; ma=86400

curl -sI https://ipinfo.io/json | grep -i '^alt-svc:'
# Alt-Svc: h3=":443"; ma=2592000
Enter fullscreen mode Exit fullscreen mode

That Alt-Svc: h3 is a suggestion that the client try QUIC directly to :443. It assumes a client that (a) has QUIC compiled in and (b) can send UDP to the origin. Behind an HTTP forward proxy you satisfy neither: the build may not do QUIC, and the proxy only gave you a TCP CONNECT. So you can sit at a machine that got an h3 advertisement, ran a request that reports http_version=1.1, and have every piece of that be "working as intended." The advertised transport and the used transport are two different facts, and conflating them is how you end up profiling a phantom.

What to actually check, per proxy, before you trust a benchmark

Put the two ideas together and you get a small harness. The point is not to prove your proxy is TCP ??it's to record, for each proxy spec you care about, the single number that matters: what http_version came back. Compare it direct-versus-proxied and stop guessing.

#!/usr/bin/env bash
# probe.sh ??what transport ACTUALLY left the machine, direct vs through proxies.
# Usage: ./probe.sh https://www.example.com/  "http://user:pass@gw:port"  "socks5h://user:pass@gw:port"
TARGET="${1:-https://www.cloudflare.com/}"
PROXIES=("$@"); PROXIES=("${PROXIES[@]:1}")   # everything after the target is a proxy spec

echo "client build:"; curl --version | head -1 | sed 's/^/  /'
printf "\n%-42s %-12s %-8s %s\n" "ROUTE" "http_version" "scheme" "alt-svc h3 offered?"
probe () {
  local label="$1"; shift                                   # remaining args = curl opts
  local ver scheme altsvc
  ver=$(curl -s -o /dev/null -w '%{http_version}' "$@" --max-time 20 "$TARGET" 2>/dev/null)
  scheme=$(curl -s -o /dev/null -w '%{scheme}' "$@" --max-time 20 "$TARGET" 2>/dev/null)
  altsvc=$(curl -sI --max-time 20 "$TARGET" 2>/dev/null | grep -ci '^alt-svc:.*h3')
  printf "%-42s %-12s %-8s %s\n" "$label" "${ver:-ERR}" "${scheme:-?}" "$([ "$altsvc" -gt 0 ] && echo yes || echo no)"
}

probe "direct (no proxy)"                 --http2
for p in "${PROXIES[@]}"; do
  [ -z "$p" ] && continue
  probe "http-proxy: $p"   -x "http://${p#*://*//}" 2>/dev/null || probe "proxy: $p" -x "$p"
done
# Expectation to confirm, not assume: through an http:// forward proxy, http_version will be 1.1 or 2 ??never 3.
Enter fullscreen mode Exit fullscreen mode

If you prefer Python and want to reason about the tunnel itself, httpx (and most Python HTTP stacks) is the same story in miniature: it can do HTTP/2 through a proxy because HTTP/2 over a CONNECT tunnel is still TCP, and it can't do HTTP/3 through a forward proxy because there's no UDP path. You don't even need traffic to see the constraint ??the connection is a TCP socket to the gateway:

import socket, urllib.parse

def tunnel_is_tcp(proxy_url: str) -> str:
    """A forward proxy hands you a TCP socket; there is no datagram socket to
    carry QUIC/HTTP3. This is the whole reason 'HTTP/3' can't cross it."""
    u = urllib.parse.urlsplit(proxy_url if "//" in proxy_url else f"http://{proxy_url}")
    host, port = u.hostname, u.port or (443 if u.scheme == "https" else 80)
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)   # SOCK_STREAM = TCP. There is no SOCK_DGRAM proxy hop.
    s.settimeout(5)
    try:
        s.connect((host, port))
        return f"CONNECT-able TCP tunnel to {host}:{port} -> max transport is h2 (HTTP/3/QUIC=UDP cannot traverse)"
    finally:
        s.close()

print(tunnel_is_tcp("residential-gw.example:9000"))
Enter fullscreen mode Exit fullscreen mode

The lines that do the talking are SOCK_STREAM and connect(). A QUIC transport would need SOCK_DGRAM and datagram exchange with the origin; the proxy hop is, structurally, a stream socket. That's not a config you can flip.

The results, and the traps hiding in them

Here's what my box actually printed, and it reframed the whole debugging session:

ROUTE          http_version   scheme   alt-svc h3 offered?
direct         1.1            https    yes        (build has no h2/h3; h3 advertised but unusable)
Enter fullscreen mode Exit fullscreen mode

Four things I now tell people:

  1. "Proxy made it slow" is often "proxy changed the protocol." A TCP-relayed h1.1 tunnel genuinely behaves differently from a direct QUIC connection ??head-of-line blocking per connection, different connection reuse, no 0-RTT. Profile the negotiated http_version, not just wall-clock, or you'll optimize the wrong layer.
  2. A SOCKS5 gateway is not automatically better. SOCKS5 can in principle relay UDP (the UDP ASSOCIATE command), but essentially no residential/ISP gateway implements it, because per-datagram egress through a rotating pool isn't what those products are for. So --socks5-hostname usually collapses to TCP too. Verify; don't assume the "S" in SOCKS means QUIC will cross.
  3. Don't benchmark a product on a transport it can't carry. If your comparison runs direct-with-h3 versus proxied-with-h1, you're measuring the proxy and the protocol change at once, and the delta is meaningless for the proxy's own overhead. Hold the transport constant (e.g. --http1.1 on both sides) when you want a clean latency number for the gateway.
  4. There's a detection angle too, but keep it in proportion. Real browsers hitting a modern site often negotiate h3/h2; if your automation quietly runs h1.1 through a proxy while claiming to be that browser, the transport profile is one more thing that doesn't line up. I mention it as a reason to know your version, not as a fingerprinting tutorial ??the fix is just "be aware of what you're actually sending."

Once I understood that my "proxy" was really a TCP byte pipe, the residential gateway stopped looking like a throttler and started looking like what it is: a sticky, authenticated TCP tunnel to an origin, which is exactly the right primitive when you need a session to keep landing on the same exit and keep a TLS tunnel open. If you're buying one of those, the honest framing is that every mainstream residential/ISP product ??Thordata's residential proxies included ??is a TCP CONNECT gateway, and pricing is metered on bytes through that tunnel (residential starts from $0.65/GB, down from a $1.05/GB list price, as I checked on their pricing page today, 2026-09-23; verify before you quote it). None of them will give you HTTP/3, and that's fine as long as your test plan knows it.

The one habit worth keeping: never trust the transport your tool says you asked for. Print %{http_version} on every proxied request the same way you'd log the status code. HTTP/3, HTTP/2, HTTP/1.1 ??through a forward proxy it's a fact about the tunnel, and the tunnel is TCP. That single check would have saved me the afternoon, and it's three characters of curl -w.

Disclosure: I work with Thordata on technical content. The measurements above were run on my own machine and are reported as-is, including the parts where my client build was more limited than I assumed.

Top comments (0)