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, while iteration_duration averaged roughly 501 milliseconds, because our script has a deliberate sleep(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.

Frequently Asked Questions

What's a good p95 latency target for a web API? There's no universal number, it depends entirely on what the endpoint does, but a commonly used rough guideline for typical user-facing API endpoints is p95 under 200-500ms, with anything simple and cache-backed expected to be much faster. The right target should come from your own baseline measurements and user expectations, not a generic rule.
Can I run a meaningful load test from my own laptop? You can validate that a script and its thresholds work correctly, the way we did against a local server here, but a real load test needs to originate from infrastructure that isn't sharing CPU, memory, and network bandwidth with the thing you're measuring, and ideally from multiple regions if your real users are geographically distributed.
Why did the k6 thresholds pass so easily in this example? Because the target was a trivial in-memory Node.js server on localhost with no database, network latency, or real business logic, and the p95 threshold of 500ms was set generously for illustration. A real service under the same test would show meaningfully higher, more realistic latency.
Is load testing something you run once before launch, or continuously? Both, ideally. A pre-launch load test finds your baseline capacity; running load tests continuously, or on every release, as part of CI catches performance regressions introduced by new code before they reach production, the same way automated functional tests catch logic regressions.
What's the difference between throughput and requests per second? In most load-testing contexts they mean the same thing: completed requests per unit time. The distinction worth remembering is that throughput is what the system actually achieved, which can be lower than the request rate you tried to send if the system is saturated and requests start queuing, timing out, or failing.