Backend API and leaderboard testing
Next to the game server, 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. Teams usually load-test these services separately from the game server, because their SLOs, failure modes and often their infrastructure owners differ. This page shows 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 the k6 gRPC documentation. MaxoPerf runs standard k6 with the gRPC module included.
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. It arrives in bursts tied to 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 storms. When a popular in-game event triggers a common achievement (e.g. “complete 1 match”), millions of players earn it at the same moment. The achievement service sees a write spike.
These bursts differ from the steady-state WebSocket traffic on the game server. Test them with short, high-concurrency HTTP bursts instead of 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 and takes 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);}With tags: { type: 'read' } and tags: { type: 'write' } on the HTTP requests, MaxoPerf
breaks down latency by request type in the run result. The thresholds use the same 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. 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 number of players who log in at the same time during a patch release or event launch.
gRPC service test
Section titled “gRPC service test”More game backend services now 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) and check that the SLOs hold during 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, warm-state read p95 should be much lower than the cold-state value. Add a “cold cache” run to your pre-launch checklist.
- Error rate during burst. 429 responses during the write burst mean the backend is rate-limiting itself. Check 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) as well as steady-state read load.
- Use separate tests for latency-sensitive reads (leaderboard, profile) and write-heavy operations (score submit, achievement grant). 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 stops the load from being real.
- Test the leaderboard service in isolation and ignore the database’s write lock contention. The test has to run at VU counts that saturate the write path, not only 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.