Game server load testing
A game server load test answers the core question every studio asks before launch: how many concurrent players can the server handle before latency climbs, connections are refused, or game logic starts dropping events? This page shows you how to build a concurrent-session load test in MaxoPerf using k6’s WebSocket API, covering configuration, the MaxoPerf console walk-through, and how to read the results.
Before you start
Section titled “Before you start”- Read Real-time protocols — it explains why game server tests use WebSocket and what k6 measures.
- Read k6 scripts on Maxoperf — the upload and run workflow.
- Know your target: the WebSocket endpoint URL, the authentication scheme (token query param, first message, cookie), and the expected server-side message format.
What a concurrent-player test measures
Section titled “What a concurrent-player test measures”Each virtual user (VU) in MaxoPerf represents one player session. The test:
- Opens a WebSocket connection (simulating login + matchmaking handshake).
- Exchanges messages at a realistic rate (game ticks, position updates, state sync).
- Holds the connection open for the duration of a realistic session (2–5 minutes for most game types).
- Closes and — optionally — reconnects to model churn.
The key measurements are connection establishment time (ws_connecting), session
hold success rate, message latency (custom Trend metric), and error rate.
k6 script for concurrent player sessions
Section titled “k6 script for concurrent player sessions”import ws from 'k6/ws';import { check, sleep } from 'k6';import { Trend, Counter } from 'k6/metrics';
const msgLatency = new Trend('game_msg_latency_ms');const disconnects = new Counter('game_unexpected_disconnects');
export const options = { stages: [ { duration: '2m', target: 500 }, // ramp to 500 concurrent players { duration: '5m', target: 500 }, // hold — steady-state load { duration: '3m', target: 1000 }, // scale to 1 000 players { duration: '5m', target: 1000 }, // hold — observe at peak { duration: '2m', target: 0 }, // ramp down ], thresholds: { ws_connecting: ['p(95)<300'], // handshake under 300 ms game_msg_latency_ms: ['p(95)<100'], // message round-trip under 100 ms game_unexpected_disconnects: ['count<50'], // tolerate < 50 drops },};
export default function () { const token = __ENV.GAME_TOKEN; const url = `wss://gameserver.staging.example.com/play?token=${token}`;
const res = ws.connect(url, {}, function (socket) { socket.on('open', () => { // Send player-ready handshake socket.send(JSON.stringify({ type: 'join', zone: 'world-1' })); });
socket.on('message', (data) => { const msg = JSON.parse(data);
// Measure echo-based round-trip latency if (msg.type === 'tick_ack' && msg.client_ts) { msgLatency.add(Date.now() - msg.client_ts); }
check(msg, { 'no server error': (m) => m.type !== 'error', 'valid game state': (m) => m.type !== undefined, });
// Send a position update every tick if (msg.type === 'tick') { socket.send(JSON.stringify({ type: 'position', x: Math.random() * 1000, y: Math.random() * 1000, client_ts: Date.now(), })); } });
socket.on('error', () => { disconnects.add(1); });
socket.on('close', (code) => { if (code !== 1000) disconnects.add(1); // unexpected close });
// Hold session for 3 minutes — typical play session socket.setTimeout(() => socket.close(), 180000); });
check(res, { 'connected (101)': (r) => r && r.status === 101 }); sleep(1);}The stages array creates the ramp profile. The thresholds block becomes the pass/fail gate —
MaxoPerf reports a threshold violation as a failure in the run detail.
Upload and configure the test
Section titled “Upload and configure the test”- Save the script as
game-server-load.js. Open MaxoPerf console → Tests → New test. - Name the test
game-server-concurrent-playersand select the project. - In the Files tab, upload
game-server-load.jsas the Entrypoint. MaxoPerf auto-detects k6. - In Configuration, set the runner location to the regions closest to your game server (e.g.
us-east-1for a North American server). For a global title, addeu-west-1andap-southeast-1as well. - Open Secrets and add
GAME_TOKEN— the bearer token your test players will use for authentication. - Optionally add a Failure criteria in Configuration matching the thresholds in your script (p95
ws_connecting< 300 ms, etc.) so MaxoPerf fails the run automatically if the game server is not ready.
Running the test
Section titled “Running the test”Click Run. The live Overview tab shows:
- VU count — climbing through the stages ramp.
- Throughput — messages-per-second (MaxoPerf maps k6’s
ws_msgs_sentrate). - Latency —
ws_connectingas the primary latency signal;game_msg_latency_msas a custom metric visible in the metrics panel. - Error rate —
game_unexpected_disconnectsandwserrors.
How to read the results
Section titled “How to read the results”After the run completes, open the Overview tab and look for:
- Connection ramp shape. The VU count should climb smoothly through the stages. A flat VU plateau below the target means MaxoPerf couldn’t open enough connections — check the Runners tab for errors.
- p95
ws_connectinglatency. Under 300 ms is typical for a well-provisioned game server. A value climbing across the hold phase suggests the server’s connection-accept loop is saturated. - Custom metric
game_msg_latency_ms. This is the game-specific signal. Values above 100 ms p95 indicate the server is falling behind on game logic under load. - Disconnect count. A handful of unexpected disconnects is normal (network blips). A disconnect count that grows linearly with VUs indicates a server-side connection limit.
World and zone load
Section titled “World and zone load”For games with instanced worlds or zones, parameterize the zone field in the join message
using MaxoPerf’s data entities (CSV):
// index.js — reads zone names from a CSV data entityimport { SharedArray } from 'k6/data';const zones = new SharedArray('zones', function () { return JSON.parse(open('./zones.json'));});
export default function () { const zone = zones[__VU % zones.length]; // distribute VUs across zones // ... connect with zone}Upload zones.json as a Test asset alongside the entrypoint. This distributes virtual
players across zones the same way real matchmaking would.
Do / don’t
Section titled “Do / don’t”Do:
- Hold connections open for a realistic session duration (2–5 min, not 10 s).
- Use custom
Trendmetrics to measure application-level message latency, not just connection establishment. - Ramp gradually before the hold phase so autoscaling has time to respond.
Don’t:
- Use the default k6
options.durationfor session hold — usesocket.setTimeout()inside the WebSocket handler so the connection stays open for the full session window. - Ignore
game_unexpected_disconnects— even a 0.1 % disconnect rate at 10 000 players is 10 dropped sessions every second.
Where to go next
Section titled “Where to go next”- Real-time protocols — WebSocket vs TCP vs UDP.
- Latency, jitter, and packet loss — measure the right network metrics.
- Launch spike and soak — plan launch-day testing.
- By engine: WebSocket load testing — full k6 WebSocket API reference.
- Cookbook: Failure criteria / pass-fail gates — auto-fail runs on threshold violations.