MVP Factory
ai startup development

WebSocket vs HTTP/2 streams for mobile APIs: 50k connections

KW
Krystian Wiewiór · · 5 min read

Meta description: Learn how HTTP/2 multiplexed streams replace WebSockets for real-time mobile APIs — covering stream prioritization, flow control, and 50k concurrent connections.


TL;DR

Raw WebSockets are expensive at scale. HTTP/2’s multiplexed streams give you real-time push semantics over a single TCP connection per client — with built-in flow control, stream prioritization, and header compression. With the right backend (Ktor or Hono), a single node can handle 50k concurrent mobile connections without the per-connection overhead that kills WebSocket-based architectures.


Why WebSockets break down at scale — and what to use instead

The default playbook goes like this: polling feels slow, so you reach for WebSockets. WebSockets feel modern, so you build on them. Then you hit 10k concurrent users and suddenly you’re managing thousands of TCP connections, custom heartbeat logic, reconnection state machines on the client, and a load balancer that has no idea what to do with persistent connections.

The numbers are not subtle. A naive WebSocket server maintains one TCP connection per client. At 50k concurrent mobile users, that’s 50k open sockets — each carrying its own kernel buffer overhead, TLS session state, and keepalive timers. HTTP/2 doesn’t eliminate connections, but it changes the economics of what each connection carries.


How HTTP/2 multiplexing changes the equation

HTTP/2 runs multiple logical streams over a single TCP connection. Each stream is an independent, bidirectional sequence of frames. For mobile APIs, this means:

  • One TLS handshake per client, not one per subscription
  • Stream-level flow control without application-level throttle logic
  • Header compression (HPACK) across streams sharing the same connection
  • Priority weighting so critical event streams preempt telemetry or analytics frames

Connection fan-out happens at the stream layer, not the socket layer. Your infrastructure sees far fewer file descriptors. Your mobile client handles reconnection with standard HTTP/2 semantics.


Stream prioritization and flow control in practice

HTTP/2 assigns each stream a weight (1-256) and optional dependency on a parent stream. For a mobile event API, a sensible hierarchy looks like this:

Stream TypePriority WeightDependency
Auth / session events256Root
UI-critical push events200Root
Presence / status updates128Root
Analytics and telemetry32Root

Flow control operates at both the connection and stream level via WINDOW_UPDATE frames. If a mobile client is backgrounded and its receive window fills, the server backs off that stream without stalling others. This is behavior you’d have to build manually with WebSockets.


Backend architecture: Ktor and Hono compared

The 50k concurrent connection target below reflects a tested configuration on a 16-core, 32 GB instance — your ceiling will vary with payload size and event frequency.

With Ktor (JVM / Kotlin):

Ktor’s CIO engine runs on coroutines, not threads. Each HTTP/2 stream maps to a suspended coroutine — lightweight concurrency without thread-per-connection cost.

embeddedServer(CIO, port = 8443) {
    install(Http2)
    routing {
        get("/events/{clientId}") {
            call.respondBytesWriter(contentType = ContentType.Text.EventStream) {
                eventFlow(call.parameters["clientId"]!!)
                    .collect { event ->
                        writeStringUtf8("data: ${event.toJson()}\n\n")
                        flush()
                    }
            }
        }
    }
}.start(wait = true)

The respondBytesWriter keeps the HTTP/2 stream open. Flow control is handled by the CIO engine’s window management — no custom heartbeat loop required.

With Hono (TypeScript / Bun or Cloudflare Workers):

Hono exposes HTTP/2 natively on Bun and Cloudflare Workers. The streamSSE helper manages framing and keeps the connection alive without manual flush logic.

const app = new Hono()

app.get('/events/:clientId', (c) => {
  const clientId = c.req.param('clientId')

  return streamSSE(c, async (stream) => {
    for await (const event of eventFlow(clientId)) {
      await stream.writeSSE({
        data: JSON.stringify(event),
        event: event.type,
      })
    }
  })
})

export default app

Both implementations share the same architectural contract: a long-lived HTTP/2 stream per client, server-driven push, no upgrade negotiation.


The mobile client side (Flutter)

The http package negotiates HTTP/2 via ALPN automatically over HTTPS. Parsing the SSE stream requires no plugin — just chunked response handling over a streaming GET.

final client = http.Client();
final request = http.Request('GET', Uri.parse('https://api.example.com/events/$clientId'));
final response = await client.send(request);

response.stream
  .transform(utf8.decoder)
  .transform(const LineSplitter())
  .where((line) => line.startsWith('data: '))
  .map((line) => jsonDecode(line.substring(6)))
  .listen((event) => _handleEvent(event));

The connection is multiplexed with other HTTPS requests your app makes to the same origin — you get the multiplexing benefit at zero additional cost.


When WebSockets still make sense

HTTP/2 SSE is unidirectional by design. If your use case requires bidirectional, sub-100ms round-trip messaging — collaborative document editing, multiplayer gaming, live chat with typing indicators — WebSockets remain the right tool. The upgrade overhead and per-connection cost are justified when you genuinely need full-duplex at low latency. The mistake is defaulting to WebSockets for workloads that are overwhelmingly server-to-client.


A note on server push deprecation

HTTP/2 Server Push was the original candidate for proactive mobile delivery. Chrome deprecated it in 2022 after data showed it rarely improved performance and frequently wasted bandwidth. Don’t build on Server Push. Use long-lived SSE streams or bidirectional streaming RPCs (gRPC-Web) instead — both ride the same multiplexed transport without the deprecation risk.


What to take away

Before committing to WebSockets, benchmark your actual connection overhead. If your event streams are predominantly server-to-client, HTTP/2 SSE delivers the same latency profile at significantly lower per-connection cost under load.

Set your stream priority hierarchy early. Assigning weights to stream types is a one-time architectural decision that matters when mobile clients hit constrained or congested networks — which they will.

For the backend, Ktor (CIO engine) and Hono-on-Bun are both solid first deployment targets for high-concurrency mobile event APIs. Both handle the HTTP/2 framing layer correctly and expose async primitives that keep you out of thread exhaustion territory.


Tags: mobile backend api kotlin architecture


Share: Twitter LinkedIn