Performance Testing
Latency, throughput and what breaks first — designing a performance test that answers a question, reading percentiles honestly, and putting a check in the pipeline.
2 min read · updated 19 September 2026
A performance test has to answer a question. "Is it fast?" is not one. "Can the checkout endpoint serve 200 requests a second with a 99th percentile under 400ms on the production instance size?" is.
Without that specificity you get a number, and a number with no threshold attached changes nobody's behaviour.
#Percentiles, not averages
Latency distributions have long tails. The mean hides them.
p50 82ms half of requests
p90 140ms
p95 210ms
p99 2,400ms ← one request in a hundred
p999 8,100ms ← the ones people tweet about
mean 118ms ← tells you almost nothing usefulIf a page makes twenty requests, "one in a hundred is slow" means roughly one page view in five contains a slow request. The p99 is not an edge case; it is your Tuesday.
Report p50, p95 and p99. Set thresholds on p95 and p99. Never gate on a mean.
#A test with a threshold
// k6 — the most usable of the load-testing tools in 2026.
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Trend } from 'k6/metrics';
const checkoutLatency = new Trend('checkout_latency', true);
export const options = {
scenarios: {
steady: {
executor: 'constant-arrival-rate', // arrival rate, not VUs — see below
rate: 200,
timeUnit: '1s',
duration: '5m',
preAllocatedVUs: 100,
maxVUs: 500
}
},
// The test fails if these are not met. This is the part that matters.
thresholds: {
'http_req_failed': ['rate<0.001'],
'checkout_latency': ['p(95)<400', 'p(99)<1000']
}
};
export default function () {
const basket = http.post(`${__ENV.BASE_URL}/api/basket`, JSON.stringify({ sku: 'book-1' }), {
headers: { 'Content-Type': 'application/json' }
});
check(basket, { 'basket created': (r) => r.status === 201 });
const started = Date.now();
const order = http.post(`${__ENV.BASE_URL}/api/orders`, basket.body, {
headers: { 'Content-Type': 'application/json' }
});
checkoutLatency.add(Date.now() - started);
check(order, { 'order placed': (r) => r.status === 201 });
sleep(1);
}Use an arrival-rate executor, not a fixed number of virtual users. With fixed VUs, each one waits for its response before sending the next, so as the system slows down the offered load falls — the test quietly stops applying pressure at exactly the moment you needed it to. This is coordinated omission, and it makes most homegrown load tests optimistic by a large factor.
#The tools
| Tool | Language | Notes |
|---|---|---|
| k6 | JavaScript | best ergonomics; thresholds as first-class; good CI story |
| Gatling | Scala/Java/Kotlin | excellent reports, strong on complex scenarios |
| JMeter | GUI/XML | ubiquitous, enormous plugin ecosystem, painful to version-control |
| Locust | Python | write scenarios in Python; easy to extend |
| wrk / oha / autocannon | CLI | single-endpoint microbenchmarks; fine for a quick answer |
#Micro-benchmarks
For a function rather than a system, use a benchmarking harness that
controls for JIT warm-up, dead-code elimination and measurement overhead.
A hand-rolled for loop with timestamps gets all three wrong.
// C#, BenchmarkDotNet
[MemoryDiagnoser]
public class PricingBenchmarks
{
private readonly Order _order = OrderBuilder.WithLines(50);
[Benchmark(Baseline = true)]
public int Current() => Pricing.ApplyDiscount(_order, Policy).DiscountCents;
[Benchmark]
public int Proposed() => PricingV2.ApplyDiscount(_order, Policy).DiscountCents;
}// Java, JMH
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@Fork(2) @Warmup(iterations = 3) @Measurement(iterations = 5)
public class PricingBenchmark {
@Benchmark public int current(State s) { return Pricing.applyDiscount(s.order, POLICY).discountCents(); }
}#In the pipeline
Full load tests need a production-like environment and produce noisy results on shared CI hardware. What does belong on every pull request is a smoke performance check: enough to catch an order-of-magnitude regression.
# GitHub Actions
- name: Performance smoke
run: |
docker compose up -d --wait
k6 run --vus 10 --duration 60s --quiet perf/checkout.js
env:
BASE_URL: http://localhost:3000// Thresholds generous enough to survive noisy CI, tight enough to catch a
// 10x regression. The full run happens nightly against a real environment.
export const options = {
thresholds: { 'http_req_duration': ['p(95)<2000'], 'http_req_failed': ['rate<0.01'] }
};#Front-end performance
Server latency is half the story. The other half is what the browser does with the response.
// Playwright + Lighthouse, as a budget check rather than a score chase
import { playAudit } from 'playwright-lighthouse';
test('the product page stays within its performance budget', async ({ page }) => {
await page.goto('/products/field-notes');
await playAudit({
page,
thresholds: { performance: 85, accessibility: 95 },
// Budgets are more actionable than the composite score
config: { settings: { budgets: [{ resourceSizes: [{ resourceType: 'script', budget: 180 }] }] } }
});
});A JavaScript bundle-size budget is the single most effective front-end performance check, because bundle size is the metric that regresses quietly and monotonically.
#What to measure alongside
A latency number with no context is hard to act on. Capture, for the same window:
- Error rate. Fast failures look like good latency.
- Throughput actually achieved, versus offered.
- Saturation — CPU, memory, connection pool, queue depth. This is what tells you what to fix.
- Database time specifically. In most web systems the answer is in here — see database testing.
#The trap
The most common performance-testing mistake is testing a system that is not under realistic data conditions. A query that is 4ms against 1,000 rows and 4 seconds against 10 million is the most common production performance incident there is, and no amount of load against an empty database will find it.
Seed production-like volume — generated, never copied, for the reasons in test data management — before believing any number.
Common questions
- What is the difference between performance testing and load testing?
- Performance testing asks how fast the system is under a defined condition. Load testing asks what happens as the condition gets worse — more users, more data, more concurrency — and where it breaks. They use the same tools and answer different questions.
- Why is the average response time misleading?
- Because latency distributions are not symmetric. A system with a 100ms mean can easily have a 2-second 99th percentile, which is the experience of one request in a hundred — and with twenty requests per page view, that is most page views touching at least one slow request. Report percentiles, not means.
- Should performance tests run in CI?
- A small, fast one should — enough to catch an order-of-magnitude regression in a critical path. Full load tests need a production-like environment and are too slow and too noisy for every pull request; run those on a schedule or before a release.
Runnable samples for this page
last test results ↗- JavaScript (k6)
k6/quality/performance-testing
Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.
Was this page useful?
Related topics
- Load, Stress and Soak TestingFinding where a system breaks rather than how fast it is — load profiles, the difference between the four kinds of test, and reading the results honestly.
- Database TestingTesting migrations, constraints, queries and transactions — the layer everything else depends on, and the one most suites replace with a mock.
- Testing in GitHub ActionsA complete pipeline — unit tests, integration with containers, sharded Playwright, coverage and artefacts — plus the caching and concurrency settings that make it fast.
- End-to-End TestingWhat belongs in an end-to-end suite and what does not, how many journeys are enough, and the practices that keep a browser suite from becoming the thing everyone ignores.
- Running Tests in ParallelHow parallelism turns hidden coupling into failures, the shared resources that clash, and how to make a suite genuinely safe to run concurrently.
- API TestingTesting HTTP and message interfaces directly — the level most teams under-invest in relative to its value, and how it replaces most slow end-to-end tests.