Part 8 was full of numbers: sockets per process, memory per connection, headroom for reconnect storms. None of them are real until you measure them against your own code. Load testing a chat server is different from testing an HTTP API, because the interesting load is not requests per second. It is thousands of connections held open for minutes, messages fanning out between them, and the latency from one user pressing send to another user seeing it.
What to measure
- —Concurrent connections held successfully, and how many failed to connect or dropped mid-test.
- —Connect time, from TCP open to the Socket.IO connect packet, including auth. This is what a reconnect storm stresses.
- —Ack latency: from
message:sendto the server acknowledgement. It covers validation and the Postgres insert. - —End-to-end latency: from the sender pressing send to a recipient receiving
message:new. This is what users feel, and it includes the Redis adapter hop. - —Server-side health: CPU, RSS, event-loop lag, Postgres connections and Redis CPU, captured at the same time as the client metrics.
Speaking Socket.IO over a raw WebSocket
k6 does not ship a Socket.IO client, but it does not need one. Socket.IO runs over Engine.IO, and with the WebSocket transport the protocol is a handful of text frames you can read and write yourself. Knowing it also makes debugging with browser dev tools much easier:
- —Connect to
/socket.io/?EIO=4&transport=websocket. - —The server sends an Engine.IO open packet:
0followed by JSON withsid,pingIntervalandpingTimeout. - —The client connects to the default namespace with
40, or with auth:40{"token":"..."}. The server replies40{"sid":"..."}on success or44{"message":"..."}on aconnect_error. - —The server sends ping
2everypingInterval; the client must answer pong3or it is disconnected. - —An event is
42plus a JSON array:42["message:new",{...}]. - —An event expecting an ack carries an ID:
4217["message:send",{...}], and the server answers4317[{...}]with the same ID.
The test script
The script below ramps up virtual users, each holding one authenticated connection for a few minutes. Users are grouped into conversations of five, so every message fans out to four recipients. Each message body embeds sentAt, so recipients can compute end-to-end latency, and the sender tracks ack latency by ack ID.
import ws from 'k6/ws';
import { check } from 'k6';
import { SharedArray } from 'k6/data';
import { Counter, Rate, Trend } from 'k6/metrics';
// Pre-generated test users: [{ userId, token, conversationId }], five per conversation.
const users = new SharedArray('users', () => JSON.parse(open('./users.json')));
const connectTime = new Trend('chat_connect_time', true);
const ackLatency = new Trend('chat_ack_latency', true);
const e2eLatency = new Trend('chat_e2e_latency', true);
const connectErrors = new Rate('chat_connect_errors');
const messagesReceived = new Counter('chat_messages_received');
export const options = {
scenarios: {
chat: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '3m', target: 5000 }, // ramp: also tests the connect path
{ duration: '10m', target: 5000 }, // hold: steady-state messaging
{ duration: '2m', target: 0 },
],
gracefulRampDown: '30s',
},
},
thresholds: {
chat_connect_time: ['p(95)<1000'],
chat_ack_latency: ['p(95)<150', 'p(99)<400'],
chat_e2e_latency: ['p(95)<250', 'p(99)<600'],
chat_connect_errors: ['rate<0.01'],
checks: ['rate>0.99'],
},
};
const URL = __ENV.WS_URL + '/socket.io/?EIO=4&transport=websocket';
const SESSION_MS = 5 * 60 * 1000;
const SEND_EVERY_MS = 10 * 1000;
export default function () {
const user = users[(__VU - 1) % users.length];
const started = Date.now();
const pendingAcks = {};
let ackId = 0;
let connected = false;
const res = ws.connect(URL, {}, function (socket) {
socket.on('message', function (data) {
if (data === '2') return socket.send('3'); // ping -> pong
if (data[0] === '0') {
// Engine.IO open: connect to the namespace with auth
return socket.send('40' + JSON.stringify({ token: user.token }));
}
if (data.startsWith('40')) {
connected = true;
connectTime.add(Date.now() - started);
connectErrors.add(0);
socket.setInterval(function () {
const id = ackId++;
const clientId = user.userId + '-' + id;
pendingAcks[id] = Date.now();
const payload = {
conversationId: user.conversationId,
clientId: clientId,
body: JSON.stringify({ sentAt: Date.now(), from: user.userId }),
};
socket.send('42' + id + JSON.stringify(['message:send', payload]));
}, SEND_EVERY_MS);
return;
}
if (data.startsWith('44')) {
connectErrors.add(1);
return socket.close();
}
if (data.startsWith('43')) {
const id = parseInt(data.slice(2), 10);
const [result] = JSON.parse(data.slice(2 + String(id).length));
check(result, { 'message acked ok': (r) => r && r.ok === true });
if (pendingAcks[id]) ackLatency.add(Date.now() - pendingAcks[id]);
delete pendingAcks[id];
return;
}
if (data.startsWith('42')) {
const [event, msg] = JSON.parse(data.slice(2));
if (event === 'message:new' && msg.senderId !== user.userId) {
const { sentAt } = JSON.parse(msg.body);
e2eLatency.add(Date.now() - sentAt);
messagesReceived.add(1);
}
}
});
socket.on('error', function () {
if (!connected) connectErrors.add(1);
});
socket.setTimeout(function () {
socket.close();
}, SESSION_MS);
});
check(res, { 'upgraded (101)': (r) => r && r.status === 101 });
}A few things are deliberate. The script answers pings, otherwise the server would drop every connection after about 45 seconds and you would be load testing your reconnect logic by accident. Every message:send goes through the real ack path, idempotency key and all, so the Postgres insert from part 4 is part of the test. And recipients measure latency from a timestamp set by the sender, which is valid as long as sender and recipient run on the same machine, or on machines with synchronised clocks.
Running it
# One generator, against staging
k6 run -e WS_URL=wss://chat-staging.example.com load/chat.js
# Summary to JSON for comparing runs over time
k6 run -e WS_URL=wss://chat-staging.example.com --summary-export=results/$(date +%F).json load/chat.jsIn the ws module, each virtual user is a JavaScript runtime holding one connection, so a single generator machine tops out somewhere in the tens of thousands of connections depending on its memory. Watch the generator's own CPU and memory during the run: an overloaded load generator reports latency that belongs to itself, not to your server. k6 also has a newer browser-style WebSockets API that lets one VU hold several connections, which raises the ceiling per machine if you need it.
Going beyond one load generator
Remember the 4-tuple from part 8. Every connection from a single generator to one server address needs a unique source port, and there are only about 64,000 of them per source IP. To test 200,000 connections you need several generators, each with a widened ip_local_port_range and raised file descriptor limits. Split the users file so generators do not share accounts, run the same script on each, and aggregate the results, either by streaming metrics to a shared backend such as Prometheus remote write or by using Grafana Cloud k6 or the k6 Operator on Kubernetes to orchestrate the run.
Watch the server at the same time
Client metrics tell you that latency went up. Server metrics tell you why. Before the run, make sure you can see these on one dashboard, with the same time axis as the k6 results:
import { monitorEventLoopDelay } from 'node:perf_hooks';
const loop = monitorEventLoopDelay({ resolution: 20 });
loop.enable();
setInterval(() => {
console.log({
sockets: io.engine.clientsCount,
loopP99ms: Math.round(loop.percentile(99) / 1e6),
rssMB: Math.round(process.memoryUsage().rss / 1e6),
});
loop.reset();
}, 10_000);- —Event-loop lag rising above 50 to 100 ms means the process is CPU-bound; everything queued behind it, including pongs, waits.
- —RSS climbing steadily during the hold phase, with a flat connection count, points to a leak: listeners not removed, maps not cleaned on disconnect.
- —Postgres pool wait time growing means inserts, not sockets, are the bottleneck; revisit pooling from the scaling series.
- —Redis CPU near one core is the classic adapter hitting its limit; that is when the sharded adapter from part 7 pays off.
Test shapes worth running
- —Ramp test: increase connections until thresholds fail. The point just before is your per-node capacity for the plan in part 8.
- —Soak test: hold a realistic load for several hours. Chat servers fail slowly, through leaks and growing maps, and only a long run reveals it.
- —Storm test: hold a steady load, then kill one server process and measure how long the rest take to absorb its clients and whether latency recovers.
- —Fan-out test: a few very large rooms with active senders, to measure the cost of broadcast rather than the cost of holding connections.
Reading the results
Compare runs, not single numbers. Keep each run's summary in version control next to the commit it tested, and look for the knee in the curve: the connection count where p99 end-to-end latency stops growing gently and starts climbing steeply. That knee, minus headroom, is the per-node number that goes into your capacity plan. If ack latency is fine but end-to-end latency is poor, the problem is in fan-out, the adapter or Redis. If both are poor, look at the event loop and Postgres first.
Key takeaways
- —Measure connections held, connect time, ack latency and end-to-end latency, alongside server event-loop lag.
- —Socket.IO over WebSocket is simple text framing:
40to connect,2and3for heartbeats,42for events,43for acks. - —Always answer pings in test clients, and embed timestamps in payloads to measure what users actually feel.
- —Spread large tests across generators to stay within ephemeral port and memory limits, and watch generators too.
- —Run ramp, soak, storm and fan-out tests; each reveals a different failure.
Relay scales, and now we have the numbers to prove it. Next up, the final part 10: Production Hardening: Backpressure, Deploys and Push, where we protect the system from abusive clients, deploy without dropping users and reach people who are offline.
