What you'll learn
Quick Answer
Load testing throws simulated concurrent traffic at a system and measures three things: throughput (requests handled per second), latency distribution (especially tail percentiles like p95 and p99, not just the average), and error rate as load increases. We installed k6 and ran a real load test against a local server to show exactly what that output looks like and how to read it.
What Load Testing Actually Measures
Load testing means sending many simulated concurrent users, or requests, at a system and measuring how it behaves under that concurrency, as opposed to functional testing, which checks whether a single request produces the right answer regardless of how many other requests are happening at the same time. Three numbers matter, and they matter together, not individually:
- Throughput — how many requests per second the system actually completes. This is not the same as how many you're sending; if you fire 500 requests/second at a server that can only process 200, throughput plateaus at roughly 200 while the rest queue up, retry, or fail.
- Latency, as a distribution, not an average. The average response time is close to useless on its own, because it hides how bad the worst requests are. This is why load-testing tools report percentiles: p50, the median, where half your requests are faster than this; p95, where 95% of requests are faster than this and 5% are slower; and p99, where only the worst 1% are slower than this. A system with a 50ms average and a 4-second p99 is telling you that some meaningful slice of real users are having a terrible time, even though "average response time: 50ms" sounds fine on a dashboard.
- Error rate under load. A system that returns correct responses at low concurrency can start returning timeouts, connection resets, or 5xx errors once concurrency crosses some threshold, usually because a connection pool, a thread pool, or a downstream dependency saturates. Error rate as a function of concurrency is often the single most useful chart a load test produces, because it tells you exactly where the system falls over.
The point of all this isn't to produce a single "score" for your system — it's to find the concurrency level at which each of these three numbers starts getting unacceptable, so you know your actual capacity before real traffic finds it for you.
Load Testing vs Stress Testing vs Soak Testing
"Load testing" gets used loosely to mean several distinct tests, and mixing them up means you learn the wrong thing from a test run:
- Load testing, the specific kind, applies a traffic level you actually expect in production — your typical or peak real-world concurrency — to confirm the system meets its latency and error-rate targets under realistic conditions.
- Stress testing deliberately pushes concurrency well past expected levels, increasing it until something breaks, specifically to find the system's actual ceiling and how it fails: does it degrade gracefully and shed load, or does it fall over completely and take dependent systems down with it?
- Soak testing, also called endurance testing, applies a moderate, realistic load for a long duration, hours rather than minutes, to catch problems that only appear over time: memory leaks, slowly-exhausted connection pools, disk filling up with logs, or a cache that never evicts and eventually degrades performance.
- Spike testing applies a sudden, sharp jump in traffic, think a flash sale link going viral, rather than a gradual ramp, to check whether the system's autoscaling or connection handling can react fast enough to a step change rather than a slope.
Each of these needs a different test configuration, not just "more traffic." A load test that runs for 30 seconds will never catch a memory leak that takes two hours to show up; a soak test at low concurrency will never find the connection-pool exhaustion that only appears at peak load.
How a Load Testing Tool Actually Generates Traffic
Tools like k6 and JMeter both work on the same basic model: a script defines what a single simulated user does, an HTTP request or a sequence of them, often with think-time pauses between steps to mimic a real person, and the tool then runs many copies of that script concurrently. Each copy is usually called a virtual user (VU). You control load by controlling how many VUs are running at once and how that number changes over time, usually via stages: ramp up to N virtual users over some duration, hold steady at N for a while, then ramp back down.
There's an important distinction in how "concurrent users" translates into actual request rate, called the workload model. In a closed model, what a fixed number of VUs looping through a script produces, each virtual user waits for its previous request to finish before sending the next one, so if the system under test gets slower, the achieved request rate automatically drops, because VUs are stuck waiting rather than piling on more requests. In an open model, new requests arrive at a fixed rate regardless of whether previous ones have finished, which more accurately represents how real independent users behave, since a slow response to one person doesn't make other people stop clicking. Most simple load tests default to something closer to the closed model because it's easier to configure, but it's worth knowing this distinction exists: a closed-model test can understate how badly a system falls over under real, open-model production traffic, because it self-throttles in a way real traffic doesn't.
A Real k6 Run Against a Local Server
To show actual output rather than describe it, we installed k6 (version 2.2.0), started a one-line Node.js HTTP server on localhost, and ran a genuine load test against it. The server:
const http = require('http');
const server = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'ok' }));
});
server.listen(8099);
And the k6 script, which ramps from 0 to 20 virtual users over 5 seconds, holds at 20 for 10 seconds, then ramps back down, with each virtual user hitting the health endpoint and sleeping half a second between requests:
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
stages: [
{ duration: '5s', target: 20 },
{ duration: '10s', target: 20 },
{ duration: '5s', target: 0 },
],
thresholds: {
http_req_duration: ['p(95)<500'],
http_req_failed: ['rate<0.01'],
},
};
export default function () {
const res = http.get('http://127.0.0.1:8099/api/health');
check(res, { 'status is 200': (r) => r.status === 200 });
sleep(0.5);
}
Running k6 run loadtest.js against the live server produced this actual, unedited summary (the per-second progress log above it is omitted here for length):
THRESHOLDS
http_req_duration
- 'p(95)<500' p(95)=912.87µs
http_req_failed
- 'rate<0.01' rate=0.00%
TOTAL RESULTS
checks_total.......: 610 30.40854/s
checks_succeeded...: 100.00% 610 out of 610
checks_failed......: 0.00% 0 out of 610
HTTP
http_req_duration..............: avg=502.83µs min=0s med=540.79µs max=2.32ms p(90)=872.6µs p(95)=912.87µs
http_req_failed................: 0.00% 0 out of 610
http_reqs......................: 610 30.40854/s
EXECUTION
iteration_duration.............: avg=501.26ms min=500.02ms med=501.04ms max=519.9ms
iterations.....................: 610 30.40854/s
vus............................: 1 min=1 max=20
vus_max........................: 20 min=20 max=20
NETWORK
data_received..................: 115 kB 5.7 kB/s
data_sent......................: 49 kB 2.4 kB/s
Both thresholds we defined passed: p95 latency, 912.87 microseconds, was comfortably under the 500ms limit we set, and the error rate was exactly 0%. Worth being upfront about scale here: these are sub-millisecond numbers because this is a trivial Node server answering from memory over localhost with no real database, network hop, or business logic involved. A real production API under the same 20 concurrent users would show latencies at least one or two orders of magnitude higher. What this run demonstrates honestly is the mechanics and the output format, not a production-representative number.
Reading the Results: What Good and Bad Look Like
A few things in that output are worth learning to read correctly, because they're exactly what you'd check in a real test against a real service:
- The gap between avg and p95 is the whole story. Here, avg=502.83µs and p95=912.87µs, so the tail is about 1.8x the average. On a real production system, watch for this ratio getting much larger, 5x or 10x; a huge gap between average and p95 means most requests are fine but a meaningful minority are much slower, often because they're hitting a cold cache, a lock contention point, or a slow downstream dependency that the average completely hides.
- An elevated http_req_failed rate under expected load is a real signal, not noise. It means the system is actively failing requests, timeouts, connection refused, 5xx responses, not just running slowly. Latency and error rate should always be read together: a system that's slow but still returning 200s is in trouble; a system that's fast but returning 500s under load is on fire.
- Thresholds turn a load test into a pass/fail gate instead of a chart you have to eyeball. Defining
http_req_duration: ['p(95)<500']up front, rather than after the fact, is what lets a load test run automatically in a CI pipeline and fail the build the same way a broken unit test would, instead of requiring a human to interpret a dashboard every time. - Iteration duration versus request duration measure different things. In our run,
http_req_duration, the actual network request, averaged roughly 500 microseconds, whileiteration_durationaveraged roughly 501 milliseconds, because our script has a deliberatesleep(0.5)between requests to simulate a real user's think-time. If you only look at iteration duration, you'll wrongly conclude the server is slow; it's the script's sleep that dominates that number, not the server.
k6 vs JMeter: How the Two Most Common Tools Differ
k6 and Apache JMeter solve the same problem with fairly different philosophies, and the choice between them usually comes down to your team's existing skills and how the test needs to plug into a pipeline:
- Scripting model. k6 tests are written as actual JavaScript, as seen above, version-controllable, code-reviewable, and runnable straight from a CI job. JMeter tests are traditionally built through a GUI as an XML "test plan," which is less natural to code-review as a diff, though JMeter also supports scripting via Groovy or BeanShell for advanced cases.
- Resource model. k6 is written in Go and runs each virtual user as a lightweight goroutine, which means a single machine can typically simulate a much larger number of virtual users than JMeter, which historically runs each virtual user as a full JVM thread, a heavier unit that limits how many VUs a single machine can realistically drive before you need to distribute the load generation across multiple machines.
- Protocol breadth. JMeter has a long head start and broader built-in protocol support beyond HTTP: JDBC for databases directly, JMS, FTP, SOAP, and a large plugin ecosystem built up over two decades. k6 is HTTP, WebSocket, and gRPC-focused by default, with protocol extensions available but a narrower core.
- Where each tends to be used. k6 fits naturally into developer-owned CI/CD pipelines as a lightweight, code-first check. JMeter remains extremely common in larger enterprise QA organizations, including a lot of Indian enterprise and services-company QA teams, partly because of its maturity, GUI accessibility to non-programmer testers, and broad protocol support for legacy enterprise systems.
Neither is objectively better. A startup shipping an HTTP API through GitHub Actions will usually reach for k6 first; a large enterprise QA team testing a mix of web services, message queues, and databases with existing JMeter test plans has little reason to switch.
