Why a Real Load Test Is Worth It Before Launch
Lots of teams treat "load testing" as a script you run the night before launch and skim the numbers. But the real value of a load test is exposing bottlenecks early: a 2-core, 4GB entry-level cloud server can look great at 50 concurrent users and then see latency jump tenfold at 300. Better to have a script find that out than your first real user.
This checklist splits load testing into four parts: set the bar, pick the tool, run realistic scenarios, and read the metrics.
Step 1: Write Down the Pass Bar First
A load test without acceptance criteria is no test at all. Put this threshold table into your acceptance doc before you start, rather than deciding afterward:
| Metric | Healthy | Warning | Fail |
|---|---|---|---|
| CPU peak | < 60% | 80% | 90% sustained 5 min |
| Memory peak | < 70% | 85% | 90%+ and climbing |
| p95 response time | < 400ms | 800ms | 1200ms |
| Error rate (5xx/timeout) | < 0.1% | 0.5% | 1% |
| Disk I/O | no queueing | occasional stalls | persistent blocking |
Note that "85% CPU / 90% memory" are not recommended operating levels — they're "go investigate now" alerts. The question a load test answers is "at what load does this machine start degrading," not "what's the maximum it can handle."
Step 2: Pick the Right Tool
There are three common tiers of load testing tools:
| Tool | Strength | Best For | Learning Curve |
|---|---|---|---|
| wrk | Single-machine, C-based, very high throughput | Quick API/Nginx stress | Low |
| k6 | Scriptable, scenario orchestration, threshold assertions | Realistic user paths, CI | Medium |
| Locust | Python, distributed | Complex flows, Python teams | Medium |
For personal sites and small projects, k6 is usually enough: you can script real user paths (login, browse, checkout) and set thresholds so a failing run exits non-zero. A minimal k6 script:
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
stages: [
{ duration: '2m', target: 50 },
{ duration: '2m', target: 100 },
{ duration: '2m', target: 300 },
{ duration: '2m', target: 500 },
{ duration: '2m', target: 0 },
],
thresholds: {
http_req_failed: ['rate<0.005'],
http_req_duration: ['p(95)<800'],
},
};
export default function () {
const res = http.get('https://your-site.example/');
check(res, { 'status is 200': (r) => r.status === 200 });
sleep(1);
}
This ramps concurrency from 50 to 500 in four stages, then back down to 0, with two guardrails: error rate below 0.5% and p95 latency below 800ms. Cross a threshold mid-run and k6 fails the run.
Step 3: Load Test Real Traffic, Not Just the Homepage
Pounding only the homepage is the most common shortcut. Real traffic is mixed: static assets come from the CDN, product lists hit cache, and checkout hits the database directly. Cover at least three request classes:
- Static assets (images/CSS/JS): validates CDN origin fetch and bandwidth;
- Read endpoints (lists, detail pages): validates queries and cache hits;
- Write endpoints (checkout, form submits): validates transactions, locks, disk I/O.
When load testing write endpoints, watch the slow-query log and the connection pool. A lot of "failed" load tests aren't the machine's fault — the database connection count maxes out and every request queues waiting for a connection.
Step 4: Locate the Bottleneck with Metrics
Run the test with monitoring on. Here's a symptom → bottleneck reference:
| Symptom | Likely Bottleneck | Where to Look |
|---|---|---|
| Slow responses, high CPU | App logic or DB queries | top, slow-query log |
| Memory climbing, never drops | Memory leak | Process RSS, GC logs |
| Disk I/O stalled | Slow-query write amplification, log flushing | iostat, iotop |
| Bandwidth maxes out first | Static assets bypassing the CDN | iftop, CDN origin stats |
| Jittery latency, random timeouts | Connection pool exhaustion, GC pauses | Pool metrics, GC curves |
One rule while debugging: change one variable at a time. If you enable Nginx caching, add DB indexes, and switch PHP to OPcache all at once, you won't know which one actually helped.
A Real Scenario: A Flash-Sale Traffic Spike
Say you run an e-commerce mini-program for users in China. On a normal day peak concurrency is around 200, but on the annual flash-sale evening it jumps to 1,200. Don't load test at "normal 200 concurrency" — replay the actual event curve: fast climb in the first five minutes, 30 minutes at peak, then a slow tail-off.
This kind of test usually exposes two things: first, the database connection pool is sized for everyday traffic, so at peak the connections get exhausted and read endpoints start timing out; second, static assets aren't served from a separate CDN hostname, so image requests saturate the outbound bandwidth and drag down API latency. Both show up clearly in the report — long before users screenshot their complaints.
What a Load Test Should Deliver
A load test isn't just about "passing" — it should leave evidence. A decent report includes:
- Environment: machine specs, software versions, tool and parameters;
- Staged results: QPS, p95/p99 latency, and error rate per concurrency tier;
- Bottleneck list: ranked findings with evidence (screenshots, slow queries);
- Recommended actions: each tied to a concrete change, not "optimize performance."
Reference: k6 docs https://grafana.com/docs/k6/latest/ , wrk repo https://github.com/wg/wrk
Common Mistakes
A few things I regularly see go wrong: testing locally instead of against a production-like environment (local networking and caching are completely different); looking only at average latency instead of p95 (averages hide behind a few fast requests); skipping the slow-query log after the run (most latency jumps are database-caused); and running the load generator on the same box as the target server (they interfere and skew the results).