Skip to content

WebSocket load testing

A WebSocket connection is a long-lived, two-way channel. Load-testing it differs from HTTP load testing. Instead of short request-response cycles, each virtual user holds an open connection and exchanges messages over time. MaxoPerf runs WebSocket load tests through k6’s built-in WebSocket module.

  • Read k6 scripts on Maxoperf. WebSocket load testing uses k6 as the script engine.
  • Learn the connection lifecycle of the service you test. Check whether it expects a handshake message after connect, and whether it pushes events or waits for the client to poll with messages.

k6’s k6/ws module wraps the WebSocket connection lifecycle. Each VU opens one WebSocket connection and drives it:

import ws from 'k6/ws';
import { check, sleep } from 'k6';
export const options = {
vus: 50,
duration: '3m',
thresholds: {
ws_connecting: ['p(95)<500'], // connection handshake under 500ms
ws_session_duration: ['p(95)<180000'], // session holds for ~3 min
},
};
export default function () {
const url = 'wss://realtime.example.com/ws';
const token = __ENV.WS_TOKEN; // injected from MaxoPerf secrets
const res = ws.connect(
`${url}?token=${token}`,
{},
function (socket) {
socket.on('open', () => {
// Send a subscription message after connecting
socket.send(JSON.stringify({ type: 'subscribe', channel: 'prices' }));
});
socket.on('message', (data) => {
const msg = JSON.parse(data);
check(msg, {
'message has type': (m) => m.type !== undefined,
'no error payload': (m) => m.type !== 'error',
});
// Respond to server ping
if (msg.type === 'ping') {
socket.send(JSON.stringify({ type: 'pong' }));
}
});
socket.on('error', (err) => {
check(null, { 'no websocket error': () => false });
});
// Hold the connection for 30 seconds then close
socket.setTimeout(() => {
socket.close();
}, 30000);
}
);
check(res, { 'websocket connected': (r) => r && r.status === 101 });
sleep(1);
}

WebSocket servers often have a per-instance connection limit. Teams commonly run a WebSocket load test to verify that:

  1. The server accepts N concurrent connections without degradation.
  2. Message broadcast latency stays within SLO as connection count grows.
  3. The server handles graceful close and reconnect correctly.

For connection-count testing, raise vus gradually and keep the per-connection message rate low:

export const options = {
stages: [
{ duration: '1m', target: 100 }, // ramp to 100 connections
{ duration: '5m', target: 100 }, // hold — observe connection stability
{ duration: '2m', target: 500 }, // ramp to 500 connections
{ duration: '5m', target: 500 }, // hold — observe at scale
{ duration: '1m', target: 0 }, // ramp down
],
};

For services where throughput matters (e.g. order execution, real-time sensor ingest), each VU can send messages in a loop:

function (socket) {
socket.on('open', () => {
// Send one message per second for 30 seconds
let count = 0;
const interval = socket.setInterval(() => {
socket.send(JSON.stringify({
type: 'order',
symbol: 'AAPL',
qty: 1,
side: 'buy',
ts: Date.now(),
}));
count++;
if (count >= 30) {
socket.clearInterval(interval);
socket.close();
}
}, 1000);
});
}

k6 reports WebSocket-specific metrics that appear in the MaxoPerf run-detail view:

k6 metricWhat it measures
ws_connectingTime to establish the WebSocket handshake (ms)
ws_session_durationDuration of each VU’s WebSocket session (ms)
ws_msgs_sentTotal messages sent across all VUs
ws_msgs_receivedTotal messages received across all VUs

When the script makes no HTTP requests, the standard latency chart on the Overview tab shows ws_connecting (time to establish the connection). To measure round-trip latency, compute it in the script and emit a custom k6 trend metric:

import { Trend } from 'k6/metrics';
const roundTripLatency = new Trend('ws_roundtrip_latency');
// In message handler:
const sent = Date.now();
socket.send(JSON.stringify({ type: 'ping_echo', ts: sent }));
socket.on('message', (data) => {
const msg = JSON.parse(data);
if (msg.type === 'ping_echo') {
roundTripLatency.add(Date.now() - msg.ts);
}
});

Most WebSocket services authenticate with one of these:

  1. Query parameter token: pass it in the URL, as in wss://example.com/ws?token=${TOKEN}
  2. First message after connect: send a JSON auth message in the open event handler
  3. Cookie: k6 sends cookies set during an HTTP login step in the WebSocket upgrade request

Use MaxoPerf secrets for any credentials: __ENV.WS_TOKEN.

Do:

  • Hold connections open as long as real user sessions last. Short connect/disconnect cycles miss the steady-state connection pressure.
  • Handle error events in the socket handler and use check() to register them as failures.
  • Use socket.setTimeout() to close connections gracefully after the desired hold duration.

Don’t:

  • Ignore ws_session_duration. A session duration below your expected hold time means the server is closing connections under load.
  • Test unauthenticated WebSocket endpoints in production. Always confirm you are authorized to generate the connection volume you plan.
  • Mix WebSocket and HTTP requests in the same k6 default function unless the service uses both in one session (e.g. HTTP to get an auth token, then WebSocket for realtime data).