Backend API and leaderboard testing
Beyond the game server itself, every multiplayer game runs a fleet of HTTP and gRPC backend services: leaderboards, player profiles, inventory and currency, achievement systems, store fronts, and social graphs. These services are typically load-tested separately from the game server — they have different SLOs, different failure modes, and often different infrastructure owners. This page covers how to test them in MaxoPerf.
Before you start
Section titled “Before you start”- You have the API endpoints, authentication scheme, and rate limits for the services you want to test.
- Read k6 scripts on Maxoperf — HTTP API
tests use k6’s
k6/httpmodule. - For gRPC services, read k6 gRPC documentation — MaxoPerf runs standard k6 including the gRPC module.
Why backend APIs have different load profiles
Section titled “Why backend APIs have different load profiles”The game server processes a fixed set of connected players per instance. Backend API load has a different shape: event-driven bursts correlated with game events:
- Post-match leaderboard update. Every time a match ends, all players submit scores
simultaneously. For a 64-player match, that is 64 concurrent
PATCH /scoresrequests in a narrow time window. - Inventory access at session start. Every player loads their inventory on login and after each match. Login storms produce correlated inventory-read spikes.
- Achievement unlock storms. When a popular in-game event triggers a common achievement (e.g. “complete 1 match”), millions of players unlock it simultaneously, causing a write spike on the achievement service.
These burst patterns are different from the steady-state WebSocket traffic on the game server. They require short, high-concurrency HTTP burst tests rather than long-hold WebSocket session tests.
Leaderboard test — k6 example
Section titled “Leaderboard test — k6 example”Leaderboards are among the most read-heavy services in gaming. A global leaderboard for a popular title serves millions of reads per day, with write bursts at end-of-match.
import http from 'k6/http';import { check, sleep } from 'k6';
export const options = { stages: [ { duration: '1m', target: 200 }, // ramp to 200 concurrent readers { duration: '5m', target: 200 }, // hold — steady-state read load { duration: '30s', target: 1000 }, // burst — post-match write spike { duration: '2m', target: 1000 }, // hold — observe write contention { duration: '1m', target: 0 }, ], thresholds: { 'http_req_duration{type:read}': ['p(95)<100'], // reads under 100 ms 'http_req_duration{type:write}': ['p(95)<300'], // writes under 300 ms http_req_failed: ['rate<0.005'], },};
export default function () { const authHeader = { headers: { Authorization: `Bearer ${__ENV.PLAYER_TOKEN}`, 'Content-Type': 'application/json', }, };
// Read: fetch top-100 leaderboard const readRes = http.get( 'https://api.staging.example.com/v1/leaderboard/global?limit=100', { ...authHeader, tags: { type: 'read' } } ); check(readRes, { 'leaderboard read 200': (r) => r.status === 200, 'has entries': (r) => r.json('entries') && r.json('entries').length > 0, });
sleep(2);
// Write: submit post-match score const writeRes = http.patch( 'https://api.staging.example.com/v1/leaderboard/global/score', JSON.stringify({ score: Math.floor(Math.random() * 5000), match_id: `match-${__VU}` }), { ...authHeader, tags: { type: 'write' } } ); check(writeRes, { 'score submit 200': (r) => r.status === 200 });
sleep(1);}The tags: { type: 'read' } and tags: { type: 'write' } on the HTTP requests let
MaxoPerf break down latency by request type in the run result. The thresholds reference
these tags to enforce separate p95 SLOs for reads and writes.
Player profile and inventory test
Section titled “Player profile and inventory test”Inventory reads happen on every session start. The test should model the authentication → profile load → inventory load sequence that every player follows on login:
import http from 'k6/http';import { check, sleep } from 'k6';
export default function () { const base = 'https://api.staging.example.com/v1'; const headers = { Authorization: `Bearer ${__ENV.PLAYER_TOKEN}`, 'Content-Type': 'application/json', };
// 1. Load player profile const profileRes = http.get(`${base}/players/me`, { headers }); check(profileRes, { 'profile 200': (r) => r.status === 200 });
// 2. Load inventory const inventoryRes = http.get(`${base}/players/me/inventory`, { headers }); check(inventoryRes, { 'inventory 200': (r) => r.status === 200, 'has items': (r) => r.json('items') !== undefined, });
// 3. Load active challenges const challengeRes = http.get(`${base}/players/me/challenges?status=active`, { headers }); check(challengeRes, { 'challenges 200': (r) => r.status === 200 });
sleep(1);}Run this at the expected peak login rate — the player count that logs in simultaneously during a patch release or event launch.
gRPC service test
Section titled “gRPC service test”Game backend services increasingly expose gRPC APIs for lower-overhead communication between services. k6 supports gRPC natively:
import grpc from 'k6/net/grpc';import { check } from 'k6';
const client = new grpc.Client();client.load(['./proto'], 'inventory.proto'); // upload .proto as Test asset
export default function () { client.connect('grpc.staging.example.com:443', { plaintext: false });
const response = client.invoke('inventory.InventoryService/GetInventory', { player_id: `player-${__VU}`, });
check(response, { 'gRPC OK': (r) => r && r.status === grpc.StatusOK, 'has items': (r) => r.message && r.message.items.length > 0, });
client.close();}Upload the .proto file as a Test asset in MaxoPerf alongside the k6 entrypoint.
Running a backend API test in MaxoPerf
Section titled “Running a backend API test in MaxoPerf”- Create a new test named
game-leaderboard-load(or the service name). - Upload the k6 script as the Entrypoint. Add
.protofiles as Test assets if testing gRPC. - In Secrets / Environment, add
PLAYER_TOKEN— use a service account token valid for the staging environment. - In Load profile, set VUs and duration to match your burst scenario.
- Select runner locations close to your API’s deployment region.
- Click Run. On the Overview tab, watch the per-tag latency breakdown (read vs write) to confirm SLOs hold under the burst stage.
What to look for in the results
Section titled “What to look for in the results”- Read p95 vs write p95 in the burst stage. Writes almost always slow down during a post-match burst. Acceptable degradation is < 2× the baseline write p95.
- Cache hit rate proxy. If the leaderboard service serves from a cache, read p95 in a warm state should be significantly lower than in a cold state. Include a “cold cache” run as part of your pre-launch checklist.
- Error rate during burst. 429 responses during the write burst indicate the backend is rate-limiting itself. Investigate whether the limit is intentional or a config error.
Do / don’t
Section titled “Do / don’t”Do:
- Tag requests by type (read, write, profile, inventory) to get per-operation latency breakdowns in MaxoPerf results.
- Model the correlated burst pattern (post-match, login storm) not just steady-state read load.
- Use separate tests for latency-sensitive reads (leaderboard, profile) and write-heavy operations (score submit, achievement unlock) — their SLOs differ.
Don’t:
- Reuse the same player token for all VUs — most backend APIs scope leaderboard writes to the authenticated player, and deduplication logic will prevent the load from being real.
- Test the leaderboard service in isolation without considering the database’s write lock contention — the test must run at VU counts that saturate the write path, not just the service’s HTTP layer.
Where to go next
Section titled “Where to go next”- Launch spike and soak — combine all backend services in a launch-day scenario.
- Matchmaking and lobby testing — another event-driven burst pattern.
- k6 scripts on Maxoperf — full k6 HTTP upload guide.
- Cookbook: Failure criteria / pass-fail gates — fail runs on SLO violations automatically.