---
title: "WebSocket Multiplexing Over HTTP/2: The Mobile Real-Time Fix You Did Not Know You Needed"
published: true
description: "HTTP/2 silently breaks WebSocket upgrades on mobile. Here is the connect-protocol + Traefik configuration that gives you true bidirectional streaming over a single persistent connection."
tags: mobile, api, architecture, performance
canonical_url: https://blog.mvpfactory.co/websocket-http2-mobile-real-time
---
What We Will Build
By the end of this tutorial you will understand why your WebSocket connections are almost certainly falling back to HTTP/1.1 without any warning, and you will have working OkHttp (Kotlin) and URLSession (Swift) configurations using connect-protocol to get true bidirectional streaming over a single HTTP/2 connection — wired through a correctly configured Traefik reverse proxy.
Prerequisites
- Android project using OkHttp, or iOS project targeting iOS 15+
- Traefik as your reverse proxy
- A backend service that speaks h2c or h2
- Familiarity with gRPC-style streaming APIs
The Problem You Probably Have Right Now
Let me show you a pattern I see in nearly every mobile codebase I audit.
The team instruments their app, sees "WebSocket connected," and assumes the connection is riding their HTTP/2 infrastructure. It is not.
WebSocket depends 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 an 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, TLS ALPN negotiation either falls back to http/1.1 or the connection is rejected. You get one TCP connection per WebSocket — defeating the entire point of HTTP/2.
| 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 |
| Proxy compatibility | Excellent | Poor without RFC 8441 |
TCP connection overhead on LTE runs 200–400ms (Grigorik, High Performance Browser Networking, O'Reilly). Three concurrent WebSocket channels means three connections and three TLS handshakes. With HTTP/2 multiplexing, those three streams share one.
Step 1 — Android/Kotlin: OkHttp + connect-kotlin
Here is the minimal setup to get this working. Use 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)
val stream = stub.chat(headers = emptyMap())
stream.sendMessage(ChatRequest(text = "hello"))
Wrap OkHttpClient in ConnectOkHttpClient and specify NetworkProtocol.CONNECT. Force HTTP_2 first in the protocols list — OkHttp will not fall back unless the server explicitly negotiates http/1.1 via ALPN.
Step 2 — iOS/Swift: URLSession + connect-swift
let configuration = URLSessionConfiguration.default
configuration.httpAdditionalHeaders = [
"Content-Type": "application/connect+proto"
]
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.
Step 3 — Traefik Configuration
The docs do not make this obvious, but two settings here are non-negotiable:
entryPoints:
websecure:
address: ":443"
http:
tls: {}
serversTransport:
myTransport:
forwardingTimeouts:
responseHeaderTimeout: "0s"
http:
routers:
api:
rule: "Host(`api.example.com`)"
service: backend
tls: {}
services:
backend:
loadBalancer:
servers:
- url: "h2c://backend:8080"
serversTransport: myTransport
The h2c:// scheme routes to your backend over HTTP/2 cleartext. The responseHeaderTimeout: "0s" disables the default timeout that silently kills long-lived streams.
Gotchas
responseHeaderTimeout is not optional. Traefik will terminate long-lived streams after its default timeout. Set it to "0s" before you go to production — this is the one that bites people most often.
Verify ALPN before you ship anything. Use Charles Proxy or Proxyman to confirm which protocol your connections actually negotiate. If you see http/1.1 in ALPN, you are leaving performance on the table.
RFC 8441 is not a reliable fix. Support across clients, proxies, and load balancers is still inconsistent. connect-protocol sidesteps this entirely by never using the WebSocket upgrade mechanism.
Conclusion
The WebSocket-over-HTTP/1.1 fallback is one of the most common invisible performance regressions on mobile. connect-protocol, combined with correct Traefik configuration, eliminates the problem at the transport layer and gives you type-safe generated clients for both Kotlin and Swift via Buf's libraries.
Verify your ALPN negotiation today — you may be surprised what you find.
Top comments (0)