A system that passes every functional test can still go down on the first Black Friday promotion. "It works" answers whether the result is right for one user; "it holds up" answers whether it stays right, and fast, for thousands at the same time. Performance testing is the discipline that measures that second question — and it is almost always left for the eve of launch, when it's too late to change the architecture.
Functional performance is not performance under load
Opening the page and getting a response in 200 ms says little. What matters is how that number behaves when 500 people open the same page in the same second. Systems degrade non-linearly: doubling the load rarely doubles the response time — at some point a queue fills up, a connection pool runs dry and latency falls off a cliff. Performance testing exists to find that cliff before the user does.
The four types you need to tell apart
"Performance testing" is an umbrella term. Underneath it are different questions, and each type of test answers one:
| Type | Question it answers | What the load profile looks like |
|---|---|---|
| Load | Does it handle the expected load with acceptable times? | Ramps up to the expected peak and holds |
| Stress | Where is the limit and how does it break? | Goes beyond the expected until it fails |
| Soak (endurance) | Does it degrade over time? (memory leak) | Moderate load for hours |
| Spike | Does it survive a sudden peak and recover? | Abrupt jump and drop |
The common mistake is running only the load test and declaring victory. It's the stress test that reveals how the system dies — with a clean "service unavailable" error or by hanging everything until timeout? — and the soak test that catches the memory leak that only shows up in the third hour of production.
The metrics that matter (and the one that misleads)
The metric that appears most on dashboards is the one that says the least: the average response time. The average hides the worst cases. If 95 requests respond in 100 ms and 5 take 10 seconds, the average comes out around ~600 ms — a number nobody actually experienced. What matters are the percentiles.
The 95th percentile (p95) is the time below which 95% of requests fell. Saying "p95 = 800 ms" means 1 in 20 users waited longer than that. At scale, the tail (p99, p99.9) is where your angriest customers live — and since each page makes dozens of calls, the chance of a user landing in the tail on at least one of them is high.
| Metric | What it measures |
|---|---|
| Latency p50 / p95 / p99 | Typical and tail response time |
| Throughput (req/s) | How many requests the system completes per second |
| Error rate (%) | Fraction of responses that failed or timed out |
| Concurrent users | How many clients are active at the same time |
Report percentiles, never the average. The average is the metric that makes a system look healthy while 5% of your users are staring at a frozen screen.
The typical shape of a result
When you ramp up the load gradually, the chart almost always has the same shape: throughput grows along with the load while latency stays flat — until the knee of the curve, the point where the system saturates. From there on, more load brings no more throughput; it only stacks up latency and, soon after, errors.
response
time
│ ╱ ← latency shoots up
│ ╱ (saturated)
│ _____╱
│ knee of the curve ___╱
│ ___________________╱
│ '
└────────────────────────────────────▶ concurrent users
▲
usable capacity
The goal of the test is to locate that knee and make sure it sits above your expected traffic peak, with headroom. Usable capacity isn't where the system breaks — it's where it still responds within your latency agreement.
A minimal JMeter plan
JMeter is the classic tool for this: open-source, built around test plans. The minimal anatomy is a Thread Group (defines how many virtual users and over how long they join — the ramp-up), one or more Samplers (the HTTP requests) and the Listeners that collect the results.
Test Plan
└─ Thread Group (100 users, ramp-up 30s, loop 10x)
├─ HTTP Request GET /api/produtos
├─ HTTP Request POST /api/carrinho
├─ Assertion status == 200 && time < 1s
└─ Listener Summary Report (p90/p95, throughput, %error)
A detail that separates an honest test from number theater: the ramp-up. Throwing 100 users in at the same instant measures an artificial spike; bringing in 100 over 30 seconds mimics real arrival and shows the degradation happening. And run the load from a machine with network and CPU to spare — if the bottleneck is your own load generator, you're measuring the laptop, not the server.
In practice, the test runs in non-GUI mode (JMeter's graphical interface consumes too many resources to generate serious load) and becomes a pipeline step, which ties directly into testing in CI/CD — a nightly job that fails the build if p95 regresses.
Define the criterion before you run
A performance test without a target number is just pretty chart generation. Before ramping up the load, write the acceptance criterion as an SLO: something like "p95 under 500 ms and error rate under 0.1% with 200 concurrent users". With that defined, the result stops being "I found the system kind of slow" and becomes an objective verdict — pass or fail. It's the same logic as a good bug report: without the expected result, there's no defect, only opinion.
Conclusion
Performance testing answers the question functional testing doesn't: does the system stay correct and fast under real load? Pick the right type (load to validate the expected, stress to find the limit, soak for the leak, spike for the scare), report percentiles instead of averages, find the knee of the curve and anchor everything in an SLO defined before you start. Done early and inside the pipeline, it swaps the surprise on the eve of launch for a number you track all along.