Lesson 1: The Connection

Lesson 4 90 min

Phase 1: The Connection — a 10,000-socket WebSocket gateway in plain Java 21

A chat app's hardest moment isn't a busy one. It's the long, quiet stretch where ten thousand sockets are open, almost none are talking, and some are already dead without anyone knowing. This phase builds the gateway that handles that well: it holds the sockets cheaply, notices the dead ones, and lets a dropped phone pick up exactly where it left off.

What you build

  • A WebSocket server on raw java.nio, with the HTTP upgrade and RFC 6455 framing written by hand.

  • A Discord-style opcode protocol with heartbeats, identify, resume and precise close codes.

  • Two epoll reactors, a fixed worker pool with an ordered per-connection mailbox, and a timing-wheel reaper.

  • Sessions with a per-session replay ring buffer, so reconnects replay only what was missed.

  • A built-in load generator (10,000 real clients on virtual threads) and a live dashboard.

Key concepts

The Handshake and The Frame. The server turns an HTTP request into a socket, then parses frames byte by byte. Size limits come from the header before any allocation, and every malformed input gets its own close code (1002, 1003, 1007, 1009).

Protocol Design and Keep-Alive. One JSON envelope, {"op","d","s","t"}. Only dispatches carry a sequence number. Clients heartbeat on the interval from HELLO, with the first beat jittered so a reconnect wave doesn't beat in lockstep.

Zombie Killing. A hashed timing wheel holds every connection's deadline. The reaper looks at one slot per 250 ms tick instead of scanning everyone. In the demo, 330 silent clients were closed with 4009 about 7.3 s after going quiet, against a 7 s timeout.

Concurrency and Netpoll Optimization. A thread per socket cost 678 extra OS threads for 1,000 clients. Virtual threads and the NIO reactor each needed 2. The gateway uses one shared read buffer per reactor, so idle sockets hold no read memory.

Session State, The Resume Capability and The Replay Buffer. Sessions live in ConcurrentHashMap and outlive their sockets for 30 s. Each dispatch is numbered and buffered before it is sent. All 100 test clients that dropped mid-conversation resumed and got exactly their 10 missed messages each.

Serialization and Memory Profiling. The same message measured as JSON (237 bytes), ETF (204) and Protobuf wire format (93). A phantom-reference leak detector proves a forgotten listener pins 1,000 closed connections, and that the fix brings it to zero.

Architecture in one paragraph

Component Architecture

Flux Gateway — Phase 1: The Connection One JVM: hand-written WebSocket transport, worker pool, sessions with replay, a heartbeat reaper and a live dashboard Clients Load generator 10,000 virtual threads WsClient + GatewayClient masked frames, heartbeats Browser (dashboard) Transport (port 7101) Acceptor blocking accept, round-robin hand-off Reactor 0 epoll selector 64 KB read buf Reactor 1 epoll selector 64 KB read buf Handshake + FrameCodec 101 upgrade, RFC 6455 frames Processing WorkerPool one mailbox per connection in order, bounded → 4008 GatewayServer HELLO · IDENTIFY · READY HEARTBEAT → ACK SEND_MESSAGE → DISPATCH RESUME → replay Session state SessionStore ConcurrentHashMap by session id + by user 30 s resume window ReplayBuffer ring of last 256 dispatches per session gap → op 9 Zombie Reaper hashed TimingWheel, 250 ms tick one slot per tick, never a full scan silent 7 s → close 4009 Dashboard (port 7180) JDK HttpServer, virtual threads /api/metrics · /metrics POST /api/scenario/{name} Observability MetricsRegistry (LongAdder) LeakDetector (phantom refs) histogram + heap dump Benchmarks echo servers on 7110–7112 JSON vs ETF vs Protobuf HashMap vs CHM TCP text dispatch outbound queue close 4009 read browser polls /api/metrics every second Threads at 10,000 connections 1 acceptor + 2 reactors + 8 workers + 1 reaper on the server — the sockets never get a thread of their own Measured: 10,000 / 10,000 clients READY, p99 connect-to-READY 106 ms, ~12.5 KB heap per connection pair

An acceptor thread hands new sockets round-robin to two selector threads. Reactors handle the upgrade, decode frames and write queued bytes, and never parse JSON. Text messages go into a bounded, ordered mailbox per connection, which a fixed worker pool drains. Workers run the protocol, update the session store and assign sequence numbers. Each event goes into the session's replay ring before it is sent. A separate reaper thread advances the timing wheel, and a JDK HttpServer serves the dashboard and metrics.

What the dashboard shows

Flowchart

One direct message, end to end Alice sends SEND_MESSAGE to Bob; the reactor never parses JSON and the worker never touches a socket Reactor I/O thread Worker pool thread 1 · read epoll says readable into shared 64 KB buffer 2 · decode header, length, unmask partial? stash the tail 3 · validate reassemble fragments strict UTF-8 or 1007 4 · mailbox Alice's bounded inbox full → close 4008 8 · flush write Bob's queue partial → OP_WRITE 5 · parse {"op":12,"d":{...}} bad JSON → 4002 6 · route byUser.get(bob) not identified → 4003 7 · dispatch (locked) seq++ → replay.append(seq) attached? enqueue frame requestFlush(): one coalesced selector.wakeup() If Bob's socket is gone: the same step 7 still runs Session DETACHED event stored, not sent RESUME (op 6) session_id + last seq replay seq+1 … latest then RESUMED dispatch gap or unknown op 9 INVALID_SESSION

Header tiles for open sockets, live sessions, frames per second and heap. One panel per capability: handshake rejections by status, frames by opcode, live timing-wheel occupancy, the three-way concurrency table, worker queue depth, active vs detached sessions, resume and replay counts, serialization bytes and nanoseconds, a live load chart, and leak-detector results with a heap-dump button. During the load test, open sockets climb to 10,000 and fall back to zero.

State Machine

Two state machines: the socket and the session Sockets die often. Sessions are allowed to outlive them for the resume window. Connection (one per TCP socket) HANDSHAKING AWAITING_IDENTIFY READY CLOSING CLOSED 101 + HELLO IDENTIFY / RESUME flushed HEARTBEAT → ACK, re-arm deadline bad upgrade: 400 / 404 / 405 / 426 / 431 4002 / 4003 / 4004 / silent → 4009 4001 / 4008 / 4009 / 1002–1009 / peer close Session (one per IDENTIFY, survives socket loss) ACTIVE socket attached DETACHED dispatches buffered EXPIRED removed from both maps socket lost (no clean close) RESUME + replay 30 s window passes or replay gap client sends close 1000 / 1001: the user meant to leave Measured: 100 / 100 resumes replayed exactly 10 missed events each; a client too far behind the ring correctly got op 9

Key takeaway

Treat the socket as disposable and the session as the asset. Number and buffer every event before sending it, and a dropped connection becomes routine instead of an incident.

Questions & Discussion

Leave a Reply

Your email address will not be published. Required fields are marked *

System Design Fundamentals – E-Book

Free download

Free eBook: System Design Fundamentals

Create a free account and download the ebook instantly. Learn the core building blocks — scaling, caching, databases and messaging — the way interviewers expect you to explain them.

Register free & download →

Already a member? Sign in to download · See what’s inside