The Spring Boot Trap
####
A junior engineer approaches the WebSocket problem like this:
@ServerEndpoint("/gateway")
public class GatewayEndpoint {
@OnOpen
public void onConnect(Session session) {
// Magic happens here!
}
}
They deploy it. It works for 100 users. Maybe 1,000. Then they hit 50,000 concurrent connections and the application starts exhibiting 5-second GC pauses. Thread dumps show 50,000 blocked threads waiting onΒ InputStream.read(). The heap grows to 12GB despite each connection only holding a few kilobytes of state. The abstraction has hidden three critical failures:
Thread-per-connection model: EachΒ
@ServerEndpointΒ typically spawns a platform thread that blocks on socket reads. At 100k connections, youβre asking the OS to context-switch between 100k threads. The scheduler collapses.Hidden allocations: The framework parses HTTP headers intoΒ
StringΒ objects, allocatesΒHashMapΒ instances for header storage, and boxes primitive values. For 100k handshakes per minute, this creates gigabytes of short-lived garbage per second.No visibility into the protocol: When a client sends a malformed handshake or exploits Slowloris-style attacks (sending headers byte-by-byte), you canβt see it because youβre operating above the socket layer.
Discordβs Gateway doesnβt useΒ @ServerEndpoint. WhatsApp doesnβt use JSR 356. They operate at the NIO layer where one thread can multiplex 65,536 connections usingΒ Selector, and where every byte allocation is explicit.
The Failure Mode: Death by a Thousand Handshakes
####
The WebSocket handshake is deceptively simple. The client sends:
GET /gateway HTTP/1.1
Host: flux.chat
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
The server must respond with:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
ThatΒ Sec-WebSocket-AcceptΒ value isΒ Base64(SHA-1(Sec-WebSocket-Key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")). Simple, right?
Hereβs what kills naive implementations at scale:
The Heap Explosion: Parsing each header line withΒ BufferedReader.readLine()Β allocates a newΒ String. For a typical handshake with 8 headers, thatβs 8 String allocations + 1 HashMap + 8 Map.Entry objects per connection. At 10,000 handshakes/sec, youβre allocating 170,000 objects per second just to read headers. The young generation collector runs every 200ms.
The Thread Wall: If you block a thread per connection during the handshake phase (waiting for the client to send all headers), you need 10,000 threads to handle 10,000 concurrent handshakes. Linux defaults to 8MB stack per thread. Thatβs 80GB of virtual memory just for stacks before youβve stored a single byte of application data.
The Crypto Bottleneck: SHA-1 computation isnβt free. On a modern CPU, it takes ~1-2 microseconds. If youβre doing this on the selector thread (the single thread handling all I/O), youβve just introduced 10-20ms of latency for every 10,000 concurrent handshakes because the selector canβt poll for new events while itβs computing hashes.
The Flux Architecture: Reactor Pattern + Virtual Threads
####
Our architecture separates concerns:
The Selector Thread: A single OS thread running a tight loop withΒ
Selector.select(). It handles three events:
OP_ACCEPT: New client connectedOP_READ: Client sent handshake dataOP_WRITE: Ready to send 101 response
The Handshake Processor: A zero-allocation state machine that parses HTTP headers directly from aΒ
ByteBufferΒ using index arithmetic (no String splits, no regex). It extracts theΒSec-WebSocket-KeyΒ as a byte range, not a String.The Crypto Workers: Virtual threads (Project Loom) handle the SHA-1 computation. When handshake headers are complete, we submit the key bytes to a virtual thread executor. This offloads blocking work without spawning OS threads.
The Connection Registry: A lock-freeΒ
ConcurrentHashMap<SelectionKey, ConnectionState>Β tracking each socketβs phase (AWAITINGHEADERS, COMPUTINGKEY, READYFORUPGRADE).