Skip to content

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.

  • 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/http module.
  • 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 /scores requests 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.

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.

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.

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.

  1. Create a new test named game-leaderboard-load (or the service name).
  2. Upload the k6 script as the Entrypoint. Add .proto files as Test assets if testing gRPC.
  3. In Secrets / Environment, add PLAYER_TOKEN. Use a service account token valid for the staging environment.
  4. In Load profile, set VUs and duration to match your burst scenario.
  5. Select runner locations close to your API’s deployment region.
  6. 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.
  • 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:

  • 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.