Phase 2: The Naive WebSocket
Repository: Source Code on GitHub
Load Test Report: Download/View k6 HTML Report
Breaking the HTTP Chains: The Stateful Illusion
In Phase 1, we established that HTTP's stateless nature and handshake overhead are toxic to high-frequency telemetry. To achieve sub-millisecond ingestion, we must hold the TCP connection open. Enter Phase 2: The WebSocket Protocol.
By upgrading the protocol, we transition to a full-duplex, persistent communication channel. The overhead drops to practically zero (just a few bytes per frame). However, this introduces a new, far more insidious architectural challenge: State Management.
The Architectural Shift
Instead of opening and closing connections, the GPS devices now establish a single, long-lived WebSocket connection. The server maintains this connection in its local memory.
- Protocol: WebSocket (ws:// or wss://)
- Connection Lifecycle: Persistent (until explicitly closed or timed out)
- Data Format: Binary or JSON Payloads
- Latency: < 15ms (Network-bound, zero handshake overhead)
The "Naive" Implementation: The God Object
In a single-instance environment, this works flawlessly. We create a global Map in Node.js to hold all active socket references. When Device A sends a coordinate, we look up Dashboard B in the map and push the data.
import { WebSocketServer, WebSocket } from 'ws';
import { IncomingMessage, Server as HttpServer } from 'http';
import { ConnectionManager } from './connection-manager';
export class NaiveWSServer {
private wss: WebSocketServer;
constructor(server: HttpServer) {
// Attaching WS to the existing HTTP server for Port sharing
this.wss = new WebSocketServer({ server });
this.initialize();
}
private initialize() {
this.wss.on('connection', (ws: WebSocket, req: IncomingMessage) => {
// 1. Connection Establishment (Zero HTTP handshake after upgrade)
const clientId = req.headers['x-client-id'] as string;
if (!clientId) {
return ws.close(1008, 'Policy Violation: Identity Required');
}
// 2. State Binding (The Trap)
ConnectionManager.register(clientId, ws);
// 3. Full-Duplex Data Stream
ws.on('message', (data: Buffer) => {
try {
// Parsing JSON on the main thread for every frame.
// Fast for 10k req/s, but catastrophic for 1M req/s.
const payload = JSON.parse(data.toString());
// Attempting to route data from a GPS device directly to a UI Dashboard.
// This succeeds ONLY if both are connected to this exact Node.js instance.
ConnectionManager.dispatchToClient(payload.targetDashboardId, payload.data);
} catch (error) {
// Fail-fast: Drop malformed binary frames to protect the Event Loop
}
});
// 4. State Cleanup
ws.on('close', () => {
ConnectionManager.unregister(clientId);
});
});
}
}import { WebSocket } from 'ws';
/**
* THE GOD OBJECT: In-Memory State Manager
* FATAL FLAW: State is bound to the V8 heap of a single process.
* Horizontal scaling will fragment this map, isolating clients across instances.
*/
export class ConnectionManager {
// O(1) lookup, but memory consumption grows linearly with connections.
private static clients = new Map<string, WebSocket>();
static register(clientId: string, ws: WebSocket) {
this.clients.set(clientId, ws);
}
static unregister(clientId: string) {
this.clients.delete(clientId);
}
/**
* Naive Point-to-Point Routing.
* If Device A is on Instance 1, and Dashboard B is on Instance 2,
* this lookup yields 'undefined'. The message is lost into the void.
*/
static dispatchToClient(targetId: string, payload: Record<string, any>) {
const client = this.clients.get(targetId);
if (client && client.readyState === WebSocket.OPEN) {
// Serializing to string for transmission. In C++ layer later, we use pure binary.
client.send(JSON.stringify(payload));
}
}
/**
* Attempting to mitigate 'Zombie Connections' locally.
* Scans the map to cull dead TCP sockets. At 1M connections,
* this iteration alone causes massive Garbage Collection pauses.
*/
static purgeDeadConnections() {
for (const [id, ws] of this.clients.entries()) {
if (ws.readyState === WebSocket.CLOSED || ws.readyState === WebSocket.CLOSING) {
this.clients.delete(id);
}
}
}
}"A system bound to the memory of a single process is not an architecture; it is merely a script running on a server. True scale requires processes to be disposable, but data to be immortal." ā BackendEvolution Architecture Principles
The Bottleneck: The Distributed State Problem
The illusion of success shatters the moment we attempt to scale horizontally. To handle 1,000,000 connections, a single Node.js instance (single-threaded) is insufficient. We must spin up multiple instances behind a Load Balancer (e.g., Nginx, HAProxy, or AWS ALB).
This is where the "Naive" architecture fatally breaks:
1. The Isolated Islands
If a GPS Device connects to Instance A, and the Fleet Manager's web dashboard connects to Instance B, they cannot communicate. Instance A's local memory has no awareness of Instance B's local memory.
2. Memory Leaks & Zombie Connections
Holding 100,000 socket objects in a single Node.js V8 heap causes severe Garbage Collection (GC) pauses. If a client drops due to network failure without sending a closure frame, the "God Object" holds the dead reference forever, eventually crashing the server with an Out of Memory (OOM) exception.
3. Deployment Downtime
In a Stateful architecture, deploying a new version of the code requires restarting the server. Restarting drops all active WebSocket connections simultaneously, causing a massive "reconnect storm" that acts as a self-inflicted DDoS attack.
Performance Metrics (The 35k Stateful Spike)
To scientifically prove the limits of the "God Object" architecture, we pushed the Node.js WebSocket server toward its absolute memory boundaries using a k6 ramping scenario targeting 35,000 concurrent, persistent connections.
| Metric | Result (At 35,000 VUs Peak) |
|---|---|
| Max Concurrent Connections | 34,967 (System limit reached) |
| Messages Streamed | 540,254 |
| Failed Handshakes | 570 (Connections rejected by OS/Node) |
| Interrupted Iterations | 33,397 (Abrupt socket termination) |
| p(95) Session Duration | 3m28s (Threshold Breached - GC Lockup) |
The Post-Mortem: Analyzing the V8 Collapse
The initial attempt to inject 100,000 concurrent connections locally resulted in an immediate OS-level termination (zsh: killed). The operating system (macOS) intervened to protect itself from OOM (Out of Memory) caused by the load generator (k6) before Node.js even reached its limit. This is a fundamental engineering reality: You cannot simulate true enterprise scale from a single local machine; the OS file descriptors and RAM will always be the primary bottleneck.
Adjusting the spike to 35,000 connections revealed the true architectural flaw of Phase 2: The Node.js V8 Engine limit.
As the concurrent connections crossed the 30k mark, the memory footprint of the ConnectionManager Map (The God Object) expanded exponentially. The results were catastrophic:
- Garbage Collection (GC) Panic: V8 initiated "Stop-The-World" garbage collection to reclaim memory. This froze the Event Loop entirely.
- Threshold Breach: Because the Event Loop was frozen, the server could not respond to WebSocket control frames (Ping/Pong/Close). 95% of sessions dragged beyond the 3-minute mark, hanging in a zombie state.
- Mass Interruption: Over 33,000 connections were abruptly dropped as the internal memory structure collapsed.
The 1 Million Target: To achieve the ultimate goal of 1,000,000 concurrent connections, the final testing phases will not be executed on local hardware. We will deploy a distributed
k6load generation cluster against our production-grade Dedicated Servers to bypass OS-level limitations.
The Verdict: State is the Enemy of Scale
Phase 2 succeeded in achieving sub-millisecond latency and zero HTTP overhead, but it created an unscalable, fragile monolith. Memory is finite. A system bound by the memory of a single process cannot provide Absolute Control over an enterprise fleet.
To break the chains of the single process, eliminate the isolated islands, and achieve infinite horizontal scalability, we must externalize our state. The "God Object" must die, and a distributed message broker must take its place.
Welcome to Phase 3: The Redis Streams Evolution.