Skip to content

WebSocket load testing

WebSocket connections are long-lived, bidirectional communication channels. Load-testing WebSocket services is different from HTTP load testing: instead of short request-response cycles, each virtual user holds an open connection and exchanges messages over time. MaxoPerf supports WebSocket load testing through k6’s built-in WebSocket module.

  • Read k6 scripts on Maxoperf — WebSocket load testing uses k6 as the script engine.
  • Understand the connection lifecycle of the WebSocket service you are testing: does it expect a handshake message after connect? Does it push events, or does the client 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. A WebSocket load test is commonly used to verify:

  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, increase vus gradually while keeping 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

The standard latency chart on the Overview tab reflects ws_connecting (connection establishment time) when no HTTP requests are made. For round-trip latency measurement, 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 via:

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

Use MaxoPerf secrets for any credentials: __ENV.WS_TOKEN.

Do:

  • Hold connections open long enough to reflect real user sessions — 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 that drops below your expected hold time indicates the server is closing connections under load.
  • Test unauthenticated WebSocket endpoints in production — always verify you have authorization to generate the connection volume you plan to run.
  • Mix WebSocket and HTTP requests in the same k6 default function unless the service genuinely uses both in the same session (e.g. HTTP to get auth token, then WebSocket for realtime data).