# Load Testing WebSockets with k6

> Drive thousands of real Socket.IO connections with k6, measure connect time and end-to-end message latency, set thresholds, and scale tests across load generators.

- Author: Syed Wajeeh Haider
- Published: Sep 28, 2026
- Series: Real-Time Chat at Scale, part 9 of 10
- Category: Testing
- Tags: k6, Load Testing, WebSockets, Socket.IO
- Reading time: 7 min
- URL: https://syedwajeehhaider.com/load-testing-websockets

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.

- **p99**: End-to-end message latency, not just ack time
- **101**: HTTP status of a successful WebSocket upgrade
- **42**: Prefix of every Socket.IO event on the wire

## 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:send` to 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: `0` followed by JSON with `sid`, `pingInterval` and `pingTimeout`.
- The client connects to the default namespace with `40`, or with auth: `40{"token":"..."}`. The server replies `40{"sid":"..."}` on success or `44{"message":"..."}` on a `connect_error`.
- The server sends **ping** `2` every `pingInterval`; the client must answer **pong** `3` or it is disconnected.
- An **event** is `42` plus a JSON array: `42["message:new",{...}]`.
- An event expecting an ack carries an ID: `4217["message:send",{...}]`, and the server answers `4317[{...}]` 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.

*load/chat.js*

```js
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.

> **Seed realistic data:** Generate test users, conversations and tokens with a script before the run, and make the tokens long-lived enough to outlast the test. A load test that fails at minute 15 because every JWT expired has measured your auth middleware, not your chat server.

## Running it

```bash
# 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.js
```

In 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:

*server: event-loop lag is the first thing to degrade*

```ts
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.

> **An alternative:** If you prefer YAML scenarios and a real Socket.IO client under the hood, Artillery has a Socket.IO engine for v3 and v4 servers. It handles the protocol for you, at the cost of less control over the wire and heavier generators. The metrics and test shapes above apply either way.

## 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: `40` to connect, `2` and `3` for heartbeats, `42` for events, `43` for 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.
