WebSockets over HTTP/2: the mobile real-time performance fix
SEO Meta Description: HTTP/2 breaks WebSocket upgrades on mobile. Use connect-protocol with Traefik to get true bidirectional streaming over a single persistent connection.
Tags: mobile backend api architecture grpc
TL;DR
HTTP/2’s multiplexed streams are incompatible with the HTTP/1.1 Upgrade mechanism WebSocket depends on. Most Android and iOS apps silently fall back to HTTP/1.1 for WebSocket connections, burning one TCP connection per socket. The fix: use connect-protocol with proper Traefik configuration to get true bidirectional streaming over a single HTTP/2 connection — no SSE complexity, no hidden fallbacks.
The problem most teams don’t know they have
Most teams instrument their apps, see “WebSocket connected,” and assume they’re riding their HTTP/2 infrastructure. They’re not.
The WebSocket handshake relies on the Upgrade: websocket header — a purely HTTP/1.1 construct. HTTP/2 has no upgrade mechanism. It uses binary framing and stream multiplexing from the first byte. RFC 8441 introduced the extended CONNECT method to tunnel WebSocket over HTTP/2, but support across clients, proxies, and load balancers remains inconsistent.
In practice, when OkHttp or URLSession attempt a WebSocket connection against an HTTP/2 server, the TLS ALPN negotiation either falls back to http/1.1 or the connection is rejected outright. You get one TCP connection per WebSocket, defeating the entire point of HTTP/2.
Why HTTP/2 multiplexing and WebSockets don’t mix
| Feature | HTTP/1.1 WebSocket | HTTP/2 Stream |
|---|---|---|
| Upgrade mechanism | Upgrade: websocket header | Not supported natively |
| Multiplexing | No — one conn per socket | Yes — 100 concurrent streams (RFC 7540 default) |
| Bidirectional | Yes | Yes (native HTTP/2 streams; gRPC/connect are consumers) |
| Mobile fallback | Default behavior | Requires explicit support |
| Proxy compatibility | Excellent | Poor without RFC 8441 |
HTTP/2 achieves multiplexing through stream IDs within a single TCP connection. Each stream is independent and stateless from the framing layer’s perspective. A WebSocket, by contrast, upgrades a single HTTP/1.1 connection into a persistent raw byte channel — semantics that don’t map onto HTTP/2’s stream model without RFC 8441 support end-to-end.
The cost compounds fast. Independent benchmarks put TCP connection overhead at 200–400ms on LTE (Grigorik, High Performance Browser Networking, O’Reilly). An app with three concurrent WebSocket channels burns three connections and three TLS handshakes. With HTTP/2 multiplexing, those three streams share one.
The connect-protocol solution
connect-protocol, developed by Buf, runs over both HTTP/1.1 and HTTP/2 with identical semantics. For bidirectional use cases — chat, live data feeds, collaborative state — it uses standard HTTP/2 streams rather than the WebSocket upgrade mechanism.
You get:
- One persistent TCP connection shared across all streams
- Standard HTTP/2 flow control
- Full compatibility with Traefik, Envoy, and most modern reverse proxies
- No RFC 8441 dependency
OkHttp configuration (Android/Kotlin)
Using the connect-kotlin library from Buf:
val okHttpClient = OkHttpClient.Builder()
.protocols(listOf(Protocol.HTTP_2, Protocol.HTTP_1_1))
.connectTimeout(10, TimeUnit.SECONDS)
.build()
val protocolClient = ProtocolClient(
httpClient = ConnectOkHttpClient(okHttpClient),
config = ProtocolClientConfig(
host = "https://api.example.com",
networkProtocol = NetworkProtocol.CONNECT,
codec = ProtoCodec()
)
)
val stub = ChatServiceClient(protocolClient)
// Bidirectional streaming over HTTP/2 — no WebSocket involved
val stream = stub.chat(headers = emptyMap())
stream.sendMessage(ChatRequest(text = "hello"))
The key is wrapping OkHttpClient in ConnectOkHttpClient and specifying NetworkProtocol.CONNECT. Force HTTP_2 first in the protocols list — OkHttp won’t fall back unless the server explicitly negotiates http/1.1 via ALPN.
URLSession configuration (iOS/Swift)
let configuration = URLSessionConfiguration.default
configuration.httpAdditionalHeaders = [
"Content-Type": "application/connect+proto"
]
// Use connect-swift from Buf for the transport layer
let client = ProtocolClient(
httpClient: URLSessionHTTPClient(configuration: configuration),
config: ProtocolClientConfig(
host: "https://api.example.com",
networkProtocol: .connect,
codec: ProtoCodec()
)
)
URLSession on iOS 15+ supports HTTP/2 natively. The connect-swift library handles framing — no custom stream management required.
Traefik configuration
Traefik requires explicit HTTP/2 enablement and a backend that speaks h2c (HTTP/2 cleartext) or h2 (TLS).
entryPoints:
websecure:
address: ":443"
http:
tls: {}
serversTransport:
myTransport:
forwardingTimeouts:
responseHeaderTimeout: "0s" # Required for streaming
http:
routers:
api:
rule: "Host(`api.example.com`)"
service: backend
tls: {}
services:
backend:
loadBalancer:
servers:
- url: "h2c://backend:8080"
serversTransport: myTransport
responseHeaderTimeout: "0s" is not optional for streaming — Traefik will otherwise terminate long-lived streams after the default timeout.
What to do now
Before shipping anything: use Charles Proxy or Proxyman to verify which protocol your WebSocket connections actually negotiate. If you see http/1.1 in ALPN, you’re leaving performance on the table and probably don’t know it.
For new bidirectional APIs, connect-protocol is the right call. It sidesteps the HTTP/2 WebSocket incompatibility entirely, works with standard HTTP/2 infrastructure, and gives you type-safe generated clients for Kotlin and Swift via Buf’s connect-kotlin and connect-swift libraries.
On the Traefik side, the defaults will silently drop long-lived streams. Set responseHeaderTimeout: "0s" and verify h2c backend routing before going to production — this is the part that bites people most often.
In my experience, the WebSocket-over-HTTP/1.1 fallback is one of the most common invisible performance regressions on mobile. The fix has been available for a while. It just requires knowing where to look.