Speculative TLS
Going through an HTTP CONNECT proxy normally costs you two round-trips before any TLS bytes hit the wire:
- Client opens TCP to the proxy.
- Client writes
CONNECT target:443 HTTP/1.1and waits. - Proxy writes
HTTP/1.1 200 Connection establishedand waits. - Client writes the TLS ClientHello.
- Proxy relays it. Server replies. Handshake continues.
Steps 2-3 burn one full RTT. Steps 4 onward are the actual TLS handshake. On a 50ms-RTT proxy, that's 50ms of dead air doing nothing useful, every time you make a fresh proxied dial.
WithEnableSpeculativeTLS collapses that. The CONNECT request and the inner ClientHello get written to the socket in the same burst, before the proxy's even had a chance to reply with its 200. A well-behaved proxy reads the CONNECT, sets up the upstream tunnel, and immediately starts forwarding the bytes that came after the \r\n\r\n. The 200 still comes back, but the ClientHello overlaps with it instead of waiting for it. One round-trip saved.
Free win for any proxy-heavy workload. If you're making lots of fresh proxied dials, flip this on.
What it looks like on the wire
A non-speculative dial sends two distinct write bursts to the proxy:
burst 1: CONNECT httpbin.org:443 HTTP/1.1\r\nHost: httpbin.org:443\r\n\r\n
[wait for 200]
burst 2: \x16\x03\x01... (TLS ClientHello)
A speculative dial coalesces them:
burst 1: CONNECT httpbin.org:443 HTTP/1.1\r\nHost: httpbin.org:443\r\n\r\n\x16\x03\x01... (CONNECT + ClientHello in the same write)
[200 comes back overlapping with the upstream forwarding]
The lib still parses the proxy's 200 response correctly, the proxy still sees a valid CONNECT request. The only thing that changes is the timing.
Turning it on
- Go
- Python
- Node.js
- .NET
package main
import (
"context"
"fmt"
"github.com/sardanioss/httpcloak"
)
func main() {
s := httpcloak.NewSession("chrome-latest",
httpcloak.WithSessionTCPProxy("http://user:pass@proxy.example.com:8080"),
httpcloak.WithEnableSpeculativeTLS(),
)
defer s.Close()
resp, err := s.Get(context.Background(), "https://httpbin.org/ip")
if err != nil {
panic(err)
}
fmt.Println(resp.StatusCode)
}
import httpcloak
with httpcloak.Session(
preset="chrome-latest",
tcp_proxy="http://user:pass@proxy.example.com:8080",
enable_speculative_tls=True,
) as s:
r = s.get("https://httpbin.org/ip")
print(r.status_code)
const { Session } = require('httpcloak');
const s = new Session({
preset: 'chrome-latest',
tcpProxy: 'http://user:pass@proxy.example.com:8080',
enableSpeculativeTls: true,
});
const r = await s.get('https://httpbin.org/ip');
console.log(r.statusCode);
s.close();
using HttpCloak;
using var s = new Session(
preset: "chrome-latest",
tcpProxy: "http://user:pass@proxy.example.com:8080",
enableSpeculativeTls: true);
var r = await s.GetAsync("https://httpbin.org/ip");
Console.WriteLine(r.StatusCode);
When it doesn't help
- No proxy. Speculative TLS is a proxy CONNECT optimization. Direct dials don't have a CONNECT exchange to fold into the ClientHello, so the option's a no-op.
- SOCKS5 proxies. SOCKS has its own framing and the lib doesn't apply the speculative trick on the SOCKS path. Stick with HTTP CONNECT for this win.
- Already-warm connections. The savings only land on the first dial. Once the H2 or H1 connection's in the pool, requests reuse it and there's no proxy handshake to optimize.
When it can break
Some proxies are picky. They expect to read the CONNECT, write the 200, and only then start reading more bytes. If the client sends extra bytes before the 200 is fully written, the proxy might:
- Buffer the speculative ClientHello correctly and forward it upstream once the tunnel's up. Common case.
- Reject the CONNECT outright with a parse error. Rare, but seen on older Squid setups and a handful of debugging tools.
- Drop the speculative bytes silently, leaving the inner TLS handshake stuck waiting for the server's reply. This is the worst case.
If you suspect the proxy's misbehaving, turn the option off and re-test. If a fresh dial without speculative works and with speculative hangs or errors, that's your answer. Write the proxy brand down somewhere so future you remembers.
Compatibility status by proxy class
- Modern commercial residential and datacenter proxies: works. Squid 4+, Tinyproxy, mitmproxy in upstream mode, Bright Data, Oxylabs, the usual suspects. Verified in production.
- Squid 3.x with old defaults: hit or miss. Test before you trust.
- Corporate egress proxies (BlueCoat, Zscaler, Forcepoint): mostly untested in this project. Some inspect the CONNECT carefully and may not like extra bytes.
- TLS-terminating MITM proxies: doesn't matter. They terminate inside the proxy and re-originate, so the ClientHello you sent isn't the one that reaches the target anyway.
Pairing with other features
Speculative TLS plays nice with everything else in the lib:
- Works with H1, H2, and H3-over-MASQUE alike (when the dial path is HTTP CONNECT, which for H3 means MASQUE specifically).
- Works with
WithECHFrom. The ECH-wrapped ClientHello is what gets pipelined. - Works with custom JA3 / JA4 fingerprints.
- Works with session resumption. The speculative ClientHello can carry a PSK and resume in one round-trip.
Stack speculative TLS, session resumption, and ECH on a proxy-heavy workload and you've got a fully private one-RTT-to-data first request. That's a Chrome-class profile that very few clients ship with by default.