Performance Testing (JMeter & k6)
A railway booking site that works for ten testers can collapse when a lakh of people try to book festival tickets at 10 a.m. Performance testing checks how fast, stable and scalable a system is under expected and extreme load. It answers questions functional tests never ask: how many users can we handle, how slow does it get, and what breaks first?
In this lesson you will learn the main types of performance test, the metrics that matter (especially percentiles), and how to write basic load tests with Apache JMeter and Grafana k6.
Types of performance test
| Type | Question | How |
|---|---|---|
| Load test | Does it meet its goals at the expected load? | Ramp up to normal peak users and hold |
| Stress test | Where does it break, and how does it fail? | Keep increasing load beyond the expected peak |
| Spike test | Can it survive a sudden jump? | Jump from few to many users in seconds (a sale opening) |
| Soak (endurance) test | Does it degrade over hours? | Hold normal load for a long time to find memory leaks |
| Scalability test | Does adding servers add capacity? | Repeat a load test with more resources |
A good performance test starts with goals written in the test plan, for example: "with 2,000 concurrent users, 95% of search requests finish within 500 ms and less than 1% fail".
The metrics that matter
- Response time: how long one request takes, from sending to receiving the full answer.
- Throughput: how many requests the system completes per second.
- Error rate: the share of requests that fail or time out.
- Concurrent users (virtual users, VUs): how many simulated users are active at once.
- Resource use: CPU, memory, disk and database connections on the servers.
Never judge performance by the average response time alone. A few very slow requests disappear inside an average, but real users feel them. Use percentiles: the 95th percentile (p95) is the time within which 95% of requests finished. Run this calculation on a small set of measurements:
import statistics
# Response times in milliseconds for 20 requests to the search API
times = [182, 175, 190, 210, 168, 1450, 205, 199, 188, 176,
240, 185, 172, 2210, 195, 181, 230, 178, 186, 201]
errors = 1 # requests that returned an HTTP error
duration_s = 10 # the test ran for 10 seconds
cuts = statistics.quantiles(times, n=100, method="inclusive")
p50, p90, p95 = cuts[49], cuts[89], cuts[94]
print(f"Requests: {len(times)}")
print(f"Average: {statistics.mean(times):.0f} ms")
print(f"Median p50: {p50:.0f} ms")
print(f"p90: {p90:.0f} ms")
print(f"p95: {p95:.0f} ms")
print(f"Max: {max(times)} ms")
print(f"Throughput: {len(times) / duration_s:.1f} requests/s")
print(f"Error rate: {errors / len(times):.1%}")
goal_ms = 500
print("p95 goal met" if p95 < goal_ms else f"p95 goal of {goal_ms} ms FAILED")Requests: 20 Average: 356 ms Median p50: 189 ms p90: 361 ms p95: 1488 ms Max: 2210 ms Throughput: 2.0 requests/s Error rate: 5.0% p95 goal of 500 ms FAILED
Most users waited under 200 ms, yet two requests took well over a second, so the p95 goal of 500 ms fails. A team looking only at the 356 ms average might call this "fine". Real tools calculate percentiles from thousands of samples; the small sample here just shows the idea.
Why systems slow down suddenly
Response time usually stays flat while a server has spare capacity, then rises sharply as it approaches its limit, because requests start queueing. This toy model shows the typical curve:
def response_time_ms(users, capacity=200, base_ms=180):
"""A simple model: fast until the server is busy, then queues build up."""
load = users / capacity
if load < 0.7:
return base_ms
if load < 1:
return round(base_ms / (1 - load) * 0.3)
return None # overloaded: requests time out
print("Users Response")
for users in [50, 100, 140, 160, 180, 190, 200, 250]:
ms = response_time_ms(users)
print(f"{users:>5} {str(ms) + ' ms' if ms else 'timeouts'}")Users Response 50 180 ms 100 180 ms 140 180 ms 160 270 ms 180 540 ms 190 1080 ms 200 timeouts 250 timeouts
Real systems are messier, but the shape is common: everything looks fine until it suddenly is not. That is why you ramp load up in steps and watch where the curve bends, and why a test at "a little below the expected peak" is not enough.
Apache JMeter
Apache JMeter is a long-established, open-source Java tool (the current release is 5.6.3, which needs Java 8 or later). You build a test plan in its graphical interface from elements:
- Thread Group: the number of virtual users (threads), the ramp-up time and the loop count or duration.
- Samplers: the requests to send, such as an HTTP Request to
/api/search?q=bat. - Config elements: shared settings such as HTTP Request Defaults (server and port), a CSV Data Set Config for test data, and a cookie manager.
- Assertions: checks on responses, such as a Response Assertion for status 200 or a Duration Assertion.
- Listeners: result viewers and reports.
The plan is saved as a .jmx (XML) file. Use the GUI only to build and debug the plan; run real load tests in non-GUI mode, which uses far less memory, and generate the HTML dashboard report:
jmeter -n -t search-load.jmx -l results.jtl -e -o report/-n means non-GUI, -t is the test plan, -l the raw results file, and -e -o create the HTML report in the report folder.
Grafana k6
k6 is an open-source load testing tool from Grafana Labs. Tests are JavaScript files, which suit developers and fit naturally into Git and CI. k6 itself is a single program written in Go, so it is light enough to generate a lot of load from one machine.
// search-load.js
import http from "k6/http";
import { check, sleep } from "k6";
export const options = {
stages: [
{ duration: "1m", target: 50 }, // ramp up to 50 virtual users
{ duration: "3m", target: 50 }, // hold
{ duration: "1m", target: 0 }, // ramp down
],
thresholds: {
http_req_duration: ["p(95)<500"], // 95% of requests under 500 ms
http_req_failed: ["rate<0.01"], // less than 1% errors
},
};
export default function () {
const res = http.get("https://staging.example.com/api/search?q=bat");
check(res, { "status is 200": (r) => r.status === 200 });
sleep(1); // think time between user actions
}Run it with k6 run search-load.js. Each virtual user runs the default function again and again. Two details matter:
- Thresholds are your pass/fail goals. If any threshold fails, k6 marks the test as failed and exits with a non-zero code, so it can fail a CI pipeline.
- Checks only count successes and failures. A failed check does not stop or fail the test by itself; add a threshold on the
checksrate if you want that.
The sleep(1) is think time: real users pause between clicks, and leaving it out creates an unrealistic flood.
Common mistakes
- Testing from a weak laptop over Wi-Fi. The load generator becomes the bottleneck. Watch its CPU too.
- Reporting only averages. Report p95 and p99 alongside the error rate.
- No think time and one user account for everyone. Realistic data and pacing matter; caches behave very differently otherwise.
- Testing too late. Run small load tests early and often, not once before launch.
Key points
- Load, stress, spike, soak and scalability tests answer different performance questions.
- Write measurable goals, such as "p95 under 500 ms with under 1% errors at 2,000 users".
- Percentiles reveal the slow requests that averages hide.
- JMeter builds test plans from thread groups, samplers, assertions and listeners; run it in non-GUI mode for real tests.
- k6 tests are JavaScript; thresholds turn goals into pass/fail results and a non-zero exit code.
- Only test systems you own or are authorised to test.
Exercise
Change the percentile program so it also prints p99 and checks two goals: p95 under 500 ms and error rate under 2%. Then remove the two slowest requests (1450 and 2210) from the list, keep the error count at 1, and run it again. Which goals pass now?
Show solution
import statistics
times = [182, 175, 190, 210, 168, 205, 199, 188, 176,
240, 185, 172, 195, 181, 230, 178, 186, 201]
errors = 1
cuts = statistics.quantiles(times, n=100, method="inclusive")
p95, p99 = cuts[94], cuts[98]
error_rate = errors / len(times)
print(f"p95: {p95:.0f} ms, p99: {p99:.0f} ms, errors: {error_rate:.1%}")
print("p95 goal:", "PASS" if p95 < 500 else "FAIL")
print("error goal:", "PASS" if error_rate < 0.02 else "FAIL")p95: 232 ms, p99: 238 ms, errors: 5.6% p95 goal: PASS error goal: FAIL
Without the two slow requests the response-time goal passes easily, but one error in 18 requests is still 5.6%, far above the 2% goal. Speed and reliability are separate goals and must both be met.
Quick Quiz
1. Which test holds normal load for many hours to find memory leaks?
Soak tests reveal problems that build up slowly over time.
2. Why report p95 instead of only the average response time?
A few very slow requests barely move the average but show clearly in high percentiles.
3. In k6, what happens when a threshold fails?
Thresholds are pass/fail criteria that can fail a CI pipeline.
4. How should JMeter run a real load test?
The GUI is for building plans; non-GUI mode uses far fewer resources.
Learn Software Testing with a mentor
Free lessons get you started. Our Software Testing training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
