HTTPUpgrade
Like WebSocket but lighter — a bare HTTP Upgrade with no WS framing overhead.
WebSocket earned its place as the default CDN-friendly transport, but you pay for it on every
single write: a frame header, a length field, and — from the client — a 4-byte mask XORed over
every payload byte. HTTPUpgrade keeps the part that CDNs care about (the 101 Switching Protocols handshake) and throws away everything else. After the handshake it is the underlying
TCP or TLS connection, untouched.
The handshake pretends to be WebSocket — the connection never is
HTTPUpgrade performs a fake WebSocket upgrade: both sides send WebSocket-looking headers, and
neither side speaks a byte of the WebSocket protocol afterwards. The dialer writes a plain GET with Connection: Upgrade and Upgrade: websocket; the server (its own comment calls
this “a fake websocket upgrade process”) checks exactly those two headers plus the exact path —
and the host, but only if you configured one — writes back a hand-built 101 Switching Protocols response, and immediately hands the raw net.Conn to the dispatcher.
The client is equally minimal on the way back: ConnRF.Read accepts only a response whose
status line is literally 101 Switching Protocols with matching Connection/Upgrade headers.
It parses that response through a bufio.Reader capped at len(b), then drains whatever the
reader buffered — so payload bytes the server sent right behind the 101 land in your first read
instead of being lost inside a discarded buffer.
After the 101, every byte is payload
There is no per-message overhead because there is no message layer at all. The server-side
connection type is the whole story — an embedded net.Conn plus an overridden RemoteAddr() (so X-Forwarded-For from a trusted proxy is reported instead of the CDN’s address):
// transport/internet/httpupgrade/connection.go
type connection struct {
net.Conn
remoteAddr net.Addr
} Compare that with the WebSocket transport, which wraps every Write in a gorilla/websocket
binary frame: 2–10 header bytes per message, and client→server payloads XOR-masked byte by byte
as RFC 6455 demands. For a VLESS-over-TLS tunnel that masking is pure waste — the payload is
already encrypted, and nothing downstream ever unmasks frames because nothing downstream is a
WebSocket.
A typical client outbound:
{
"network": "httpupgrade",
"security": "tls",
"tlsSettings": { "serverName": "cdn.example.com" },
"httpupgradeSettings": {
"host": "cdn.example.com",
"path": "/stream?ed=2560"
}
} Host resolution on the client falls back in order host → TLS serverName → destination
address, and by default the request is dressed in Chrome-like browser headers
(Sec-Fetch-Mode: websocket, Cache-Control: no-cache, …) via TryDefaultHeadersWith. On the
server, the path must match exactly — req.URL.Path != path is a hard reject, no prefix
matching.
ed in the path changes timing, not data
Appending ?ed=N makes the dialer return without waiting for the 101, so the first payload
write leaves in the same round trip as the upgrade request — the response is validated lazily on
the first read. Without it, dialhttpUpgrade blocks on an empty read until the 101 arrives,
costing one extra RTT before any proxied byte moves.
Choose HTTPUpgrade unless you need a WebSocket-only feature
If both ends are Xray, the WebSocket protocol machinery buys you nothing — the CDN compatibility comes from the handshake, which HTTPUpgrade reproduces byte-for-byte where it matters.
| WebSocket | HTTPUpgrade | |
|---|---|---|
| Handshake through CDN | 101 upgrade | Same 101 upgrade |
| Per-write overhead | Frame header + client mask | None |
| Early data | Up to ed bytes in a header | Skips the 101 wait only |
| Keepalive pings | heartbeatPeriod | None |
| Browser dialer | Supported | Not supported |
Default to WebSocket out of habit
Every proxied write gets framed and masked by gorilla/websocket, and the connection still needs the same TLS + Upgrade handshake. You pay the WS tax without using a single WS feature.
Use HTTPUpgrade for Xray-to-Xray CDN fronting
Identical handshake on the wire, zero framing afterwards. Reach for WebSocket only when you need what HTTPUpgrade lacks: protocol-level heartbeats through idle-killing middleboxes, header-borne early data, or dialing from a real browser.
One footgun to know about: unless the TrustedXForwardedFor socket setting is non-empty, the
server parses X-Forwarded-For from any client and reports it as the remote address —
fine behind a CDN you control, spoofable if the port is reachable directly. (This is
independent of acceptProxyProtocol, which only affects the PROXY protocol prefix.)