Contents

XHTTP over HTTP/3 (QUIC): the fastest, the trickiest

Run the tunnel over QUIC for lower latency on lossy links — and the operational catches that come with UDP.

XHTTP already carries the tunnel inside ordinary HTTP; running it over HTTP/3 swaps the pipe under those requests from TCP to QUIC. The switch is entirely driven by one TLS knob — set the ALPN to h3 and nothing else changes in your XHTTP config. decideHTTPVersion inspects the negotiated ALPN: exactly one protocol named h3 selects HTTP/3, http/1.1 selects 1.1, anything else (including REALITY) falls back to HTTP/2. When h3 wins, the dialer forces the destination to UDP and builds a quic-go/http3 transport instead of an http2.Transport.

transport/internet/splithttp/dialer.go transport/internet/tls/config.go
func decideHTTPVersion(tlsConfig *tls.Config, realityConfig *reality.Config) string {
	if realityConfig != nil {
		return "2"
	}
	// …
	if tlsConfig.NextProtocol[0] == "h3" {
		return "3"
	}
	return "2"
}

One ALPN value flips the whole transport to QUIC

The only edit that turns XHTTP into XHTTP/3 is the TLS alpn list — set it to exactly ["h3"] and Xray dials QUIC over UDP/443. Everything under xhttpSettings stays identical to the TCP build, because QUIC just replaces the byte pipe beneath the same POST/GET request pattern.

{
	"protocol": "vless",
	"settings": { "vnext": [ { "address": "example.com", "port": 443, "users": [ { "id": "" } ] } ] },
	"streamSettings": {
		"network": "xhttp",
		"security": "tls",
		"tlsSettings": {
			"serverName": "example.com",
			"alpn": ["h3"]
		},
		"xhttpSettings": {
			"host": "example.com",
			"path": "/tunnel"
		}
	}
}
h3 rides QUIC on UDP/443. There is no TCP socket to fall back to if the middlebox drops UDP.

The fastest: 0-RTT dials, BBR, and no head-of-line blocking

QUIC wins on lossy and mobile links for three concrete reasons visible in the dialer. Xray dials with quic.DialEarly, runs BBR congestion control by default, and multiplexes streams inside one connection so a lost packet stalls only its own stream. On TCP a single dropped segment blocks every byte behind it; on QUIC the other streams keep flowing.

quicConn, err := quic.DialEarly(ctx, udpConn, udpAddr, tlsCfg, cfg)
// …
switch quicParams.Congestion {
case "force-brutal":
	congestion.UseBrutal(quicConn, quicParams.BrutalUp)
case "reno":
	// quic-go default reno
default:
	congestion.UseBBR(quicConn) // the default
}

DialEarly sends application data with the handshake instead of waiting a full round trip, and BBR recovers from loss far better than the loss-based control TCP defaults to. On a flaky 4G link this is the difference you feel.

The trickiest: UDP has no safety net

Everything that makes QUIC fast also makes it fragile in hostile networks. A blocked or throttled UDP/443 kills XHTTP/3 outright — there is no TCP socket on the same connection to fall back to. The h2 build survives many networks that silently drop the h3 build, and the reason is not performance.

Two more operational catches specific to QUIC:

  • CDNs rarely offer h3 to the origin. An edge may serve HTTP/3 to your client while connecting to your server over TCP h2. Behind a CDN, alpn: ["h3"] on the origin usually will not negotiate — see cdn.
  • MTU and idle timeouts. QUIC probes path MTU and needs periodic keepalives to hold a NAT mapping open on UDP. Those are the knobs below.

Tune QUIC through finalmask.quicParams, not xhttpSettings

The QUIC-specific dials live in a separate block. Receive windows, idle timeout, keepalive, and UDP-port hopping are all set under finalmask.quicParams — the same struct the dialer reads into quic.Config.

{
	"streamSettings": {
		"network": "xhttp",
		"security": "tls",
		"tlsSettings": { "serverName": "example.com", "alpn": ["h3"] },
		"finalmask": {
			"quicParams": {
				"maxIdleTimeout": 30,
				"keepAlivePeriod": 10,
				"maxStreamReceiveWindow": 16777216,
				"disablePathMTUDiscovery": false
			}
		},
		"xhttpSettings": { "host": "example.com", "path": "/tunnel" }
	}
}

Behavior straight from the source, with the defaults Xray substitutes when you omit a key:

KeyEffectDefault when 0Config bound
maxIdleTimeoutseconds before an idle QUIC conn is torn down300 (ConnIdleTimeout)4–120
keepAlivePeriodPING interval to hold NAT / path open10 (QuicgoH3KeepAlivePeriod)2–60
maxIncomingStreamsclient-side incoming stream cap-1 (http3 default)≥8
initStreamReceiveWindow / maxStreamReceiveWindowper-stream flow controlquic-go default≥16384
disablePathMTUDiscoverystop probing path MTUprobing on
udpHop.portsrotate the UDP dest port to dodge per-port throttlingno hoppinginterval ≥5s

One subtlety from the dialer: the 10-second keepalive default only kicks in when xmux.hKeepAlivePeriod is also unset. Set the XMUX knob without setting keepAlivePeriod, and QUIC keepalives are silently disabled — set both if you rely on them.

Don't

Ship h3 as your only transport

One throttled UDP/443 and every client goes dark with no fallback. QUIC is a single point of failure on hostile networks.

Do

Offer h3 first, keep an h2/TCP path behind it

Run XHTTP/3 for the latency win on good links, and keep a second outbound with alpn: ["h2"] (plain TLS or REALITY over TCP) so the client fails over when UDP is dropped.

For the request-lane mechanics XHTTP/3 inherits unchanged, see splithttp; for the ALPN plumbing see tls; for the finalmask block see finalmask.

transport/internet/config.proto infra/conf/transport_internet.go