DEV Community

Elvin Aghalarov
Elvin Aghalarov

Posted on Originally published at hoofbeat.dev

Load test your API before launch day (a one-afternoon checklist)

Launch day is the worst time to find out how many users your API can serve. A load test a week earlier answers the same question while you can still act on it. Here's one you can run in an afternoon.

1. Write down what "ready" means

Three numbers, before you send a single request:

  • Expected peak: 2,000 visitors in the launch hour × ~30 API calls each ≈ 17 requests/s on average. Peaks run 2–3× higher, so plan for 50 requests/s.
  • Latency goal: p95 under 500 ms.
  • Error goal: under 1% failed requests.

These become pass/fail thresholds. Without them a load test produces charts, not a decision.

2. Test a journey, not an endpoint

Real users don't call GET /health in a loop. They:

  1. POST /login, and keep the token
  2. GET /products?page=1
  3. GET /products/{id} for an id from the list
  4. POST /cart with that product

Pass values from step to step, use many test accounts, and add think time between steps.

3. The script

A complete k6 version: ramp, hold, thresholds, chained requests, one account per virtual user.

import http from 'k6/http';
import { check, sleep } from 'k6';
import { SharedArray } from 'k6/data';

const BASE = __ENV.BASE_URL;
const users = new SharedArray('users', () => JSON.parse(open('./users.json')));

export const options = {
  scenarios: {
    launch: {
      executor: 'ramping-arrival-rate',
      startRate: 5,
      timeUnit: '1s',
      preAllocatedVUs: 100,
      maxVUs: 1000,
      stages: [
        { target: 5, duration: '1m' },   // warm up
        { target: 50, duration: '3m' },  // ramp
        { target: 50, duration: '5m' },  // hold at peak
      ],
    },
  },
  thresholds: {
    http_req_duration: ['p(95)<500'],
    http_req_failed: ['rate<0.01'],
  },
};

export default function () {
  const user = users[Math.floor(Math.random() * users.length)];
  const json = { headers: { 'Content-Type': 'application/json' } };

  const login = http.post(`${BASE}/login`, JSON.stringify(user), { ...json, tags: { name: 'login' } });
  check(login, { 'logged in': (r) => r.status === 200 });
  const auth = { headers: { ...json.headers, Authorization: `Bearer ${login.json('token')}` } };
  sleep(2);

  const list = http.get(`${BASE}/products?page=1`, { ...auth, tags: { name: 'list' } });
  const id = list.json('data.0.id');
  sleep(3);

  http.get(`${BASE}/products/${id}`, { ...auth, tags: { name: 'product' } });
  sleep(2);

  const cart = http.post(`${BASE}/cart`, JSON.stringify({ product_id: id }), { ...auth, tags: { name: 'cart' } });
  check(cart, { 'added to cart': (r) => r.status === 201 });
}
Enter fullscreen mode Exit fullscreen mode
k6 run -e BASE_URL=https://staging.example.com launch.js
Enter fullscreen mode Exit fullscreen mode

The tags: { name } let you read results per step instead of one blended number.

4. Run it against a copy of production

Same instance sizes, same config, realistic data volumes. If you must test production: quiet hour, start small, and check your host's rules (many want notice before load tests).

5. Read the result in this order

  1. Errors first. A fast 500 is still a failure. Group by status code and step.
  2. Percentiles, not averages. p95 and p99 per step (why).
  3. The curve. Find the arrival rate where p95 starts climbing faster than the load: that's your practical capacity.

6. Fix the biggest thing, run it again

First tests almost always find one dominant bottleneck:

  • a missing index or an N+1 query
  • a connection or worker pool that's too small
  • an external API called without a timeout

Fix that one thing, run the identical test, compare the two runs side by side. Repeat until the thresholds pass with headroom.

TL;DR

  1. Define peak, p95 goal and error goal.
  2. Script a real journey with chained data and many accounts.
  3. Warm up → ramp → hold.
  4. Read errors → percentiles → curve.
  5. Fix one bottleneck, rerun, compare.

I build Hoofbeat, where you can describe this journey in plain words instead of scripting it. It runs a smoke test first so you don't load a broken flow, and keeps every run to compare before and after. Original post on the Hoofbeat blog.

Top comments (0)