Skip to content
Back home

Article — k6

Load testing with k6: what most guides get wrong

Common k6 load testing mistakes and how to avoid them: open-model arrival rates, percentiles, thresholds, realistic workloads and server-side correlation.
Reading time
9 min
Topics
k6 · Load testing · Performance · Scalability
Published
3 November 2026
Go deeper
Scale & Load Engineer skill pack

Most load tests I review were not designed to fail. They run, produce a wall of numbers, and someone screenshots the average response time for a Slack thread. The tool is rarely the problem: k6 is excellent. The problem is that the test was never built to answer a question, so it cannot tell you anything you did not already believe.

This article walks through the mistakes I see most often and what to do instead. The examples use Grafana k6, but almost all of it applies to any load testing tool.

Mistake 1: testing without a goal

"Let's see how it handles load" is not a goal. Before you write a script, write down three things:

  • Expected peak throughput. Requests per second (or business transactions per second) at your busiest realistic moment, taken from production analytics or access logs, not guessed. Add headroom for growth and marketing spikes.
  • Latency targets as percentiles. For example, p95 under 300 ms and p99 under 800 ms for the API, with tighter targets for the endpoints users wait on.
  • An error budget. The proportion of failed requests you will accept under that load, for example under 0.5%.

With those three numbers the test has a pass condition. Without them, any result can be argued into a success, and any regression can be argued away.

Mistake 2: using virtual users when real traffic is an arrival rate

This is the most important mistake on the list, and the default examples in many tutorials make it.

A virtual user (VU) loop is a closed model: each VU sends a request, waits for the response, and only then starts the next iteration. The k6 executors constant-vus and ramping-vus work this way. The problem is that throughput is now coupled to response time. When the system slows down, each VU waits longer, so fewer requests are sent, so the system gets a breather. The test politely backs off at exactly the moment you wanted to see what happens.

The k6 documentation on open and closed models names this effect: coordinated omission. The slow requests you most needed to measure are the ones the load generator stopped sending.

Real users do not coordinate with your server. They arrive whether or not the previous visitor's page has loaded. That is an open model, and k6 implements it with two arrival-rate executors.

Constant arrival rate

constant-arrival-rate starts a fixed number of iterations per timeUnit, regardless of how long each one takes. k6 adds VUs from a pool, up to maxVUs, to keep the rate steady.

export const options = {
  scenarios: {
    steady_peak: {
      executor: 'constant-arrival-rate',
      rate: 200,            // iterations started per timeUnit
      timeUnit: '1s',
      duration: '15m',
      preAllocatedVUs: 100,
      maxVUs: 400,
    },
  },
};

Ramping arrival rate

ramping-arrival-rate does the same but moves between targets through stages, starting from startRate.

export const options = {
  scenarios: {
    ramp_to_peak: {
      executor: 'ramping-arrival-rate',
      startRate: 20,
      timeUnit: '1s',
      preAllocatedVUs: 100,
      maxVUs: 500,
      stages: [
        { target: 200, duration: '5m' },  // ramp to expected peak
        { target: 200, duration: '20m' }, // hold
        { target: 0, duration: '2m' },
      ],
    },
  },
};

Two details matter here. First, the k6 docs advise against putting sleep() at the end of an arrival-rate iteration, because the executor already paces iteration starts. Second, if the pool runs out of VUs, k6 cannot start the iteration and records it in the dropped_iterations metric. Dropped iterations early in a run usually mean you under-allocated VUs. Dropped iterations that appear as load rises usually mean the system is slowing down: the dropped iterations guide covers both cases.

Closed-model tests still have a place, for example when you genuinely have a fixed population such as a pool of worker processes. For public web traffic, start with the open model.

Mistake 3: looking at averages

An average response time of 120 ms can hide one request in fifty taking four seconds. Latency distributions are long-tailed, and the tail is where users notice. If a page makes ten API calls, the chance that at least one of them lands in the slowest 1% is far higher than 1%, so p99 of a single endpoint describes something many page loads will experience.

Report p50, p95 and p99 at minimum, per endpoint where it matters, and compare them across runs. Treat the maximum with suspicion: it is one sample and often reflects a garbage collection pause or a cold connection rather than a trend.

Mistake 4: no thresholds, so the test never fails

A k6 run without thresholds exits successfully as long as the script does not crash. Thresholds turn your goals from mistake 1 into pass or fail criteria, and k6 exits with a non-zero code when one fails, which makes the test usable as a CI gate.

export const options = {
  thresholds: {
    http_req_failed: ['rate<0.005'],
    http_req_duration: ['p(95)<300', 'p(99)<800'],
    'http_req_duration{endpoint:checkout}': ['p(95)<500'],
    dropped_iterations: ['count<50'],
    checks: [{ threshold: 'rate>0.99', abortOnFail: true, delayAbortEval: '1m' }],
  },
};

A few things worth knowing:

  • http_req_failed is a Rate metric, so it uses the rate aggregation. http_req_duration is a Trend, so it supports avg, min, max, med and p(N).
  • Put multiple expressions for one metric in a single array. The docs warn that repeating the same metric as a separate object key means only one survives and the rest are silently ignored.
  • The tagged form http_req_duration{endpoint:checkout} applies a threshold to a sub-metric. You set the tag on the request yourself.
  • Checks on their own never fail a run. Put a threshold on the checks metric if a failed check should fail the test.
  • abortOnFail stops a run early, and delayAbortEval stops it aborting before enough samples exist.

Mistake 5: unrealistic workloads

A test that hammers one endpoint with the same request tells you how fast your cache is. Real traffic differs in several ways that matter:

  • Endpoint mix. Take the top routes and their relative weights from production logs and reproduce that mix. Writes, searches and report endpoints behave very differently from a cached product page.
  • Data variety. If every VU requests product 42 as user 1, every layer from the CDN to the database buffer cache will serve it from memory. Use a realistic spread of IDs, search terms and accounts. k6's SharedArray keeps large test data sets memory-efficient.
  • Think time within journeys. Real users pause between steps. In a multi-step journey, short randomised pauses between requests are realistic. With arrival-rate executors they also lengthen each iteration, so you will need more VUs to hold the same rate.
  • Authentication. Logging in on every iteration load-tests your password hashing. Logging in once in setup() and reusing a token can under-represent session load. Decide which reflects reality.
  • Payload sizes. Small fixtures make uploads and large responses look cheap.

Mistake 6: the load generator is the bottleneck

If the machine running k6 is saturated, it cannot start requests on time or measure them accurately, and your latency numbers become a measurement of the generator. The k6 guide on running large tests recommends keeping generator CPU within about 80% and memory under 90%, and monitoring network throughput, since a 1 Gbit/s interface can cap the load you can produce.

Practical habits:

  • Watch the generator's CPU, memory and network during the run, not just the target's.
  • Treat rising dropped_iterations with a healthy target as a generator or VU allocation problem.
  • Use discardResponseBodies: true when you do not need the bodies.
  • Run the generator close to the target network-wise, but not on the same host.

Mistake 7: testing systems you do not own

Load testing a third-party API or someone else's production system without written permission can breach their terms, trigger their abuse protection, run up costs on your account, and look indistinguishable from a denial-of-service attack. That includes payment providers, email APIs and identity providers called by your own application during the test.

Instead:

  • Test a staging environment that mirrors production in instance sizes, configuration and data volume.
  • Stub or mock third-party dependencies, with realistic latency injected so your connection pools and timeouts still get exercised.
  • If you must test production, agree a window, notify your hosting and CDN providers where their policies require it, and have an abort plan.
  • Check rate limits and cost implications before any stress, spike or soak run.

Mistake 8: one test type for everything

Different shapes of load answer different questions. The k6 docs describe a family of load test types:

  • Smoke: minimal load for a short time. Does the script work and does the system respond correctly? Run it before anything bigger.
  • Average load: expected production traffic for tens of minutes. Do we meet our targets on a normal busy day?
  • Stress: load above the expected peak. How does the system degrade, and does it degrade gracefully?
  • Spike: a sudden, short jump in traffic. Do autoscaling, connection pools and queues cope with a step change, and does the system recover afterwards?
  • Soak: average load held for hours. Do memory leaks, connection leaks, growing queues or log volume eventually hurt?
  • Breakpoint: steadily increasing load until something gives. Where is the ceiling?

A 10-minute stress test will not find a leak that takes three hours to matter, and a soak test will not tell you how your autoscaler handles a flash sale. Pick the type that answers the question you have.

Mistake 9: not correlating with server-side metrics

k6 tells you that p99 went from 300 ms to 2 seconds at 180 requests per second. It cannot tell you why. For that you need the target's own telemetry, on the same timeline:

  • CPU and memory per service and per instance
  • database connections in use against the pool limit, slow queries and lock waits
  • queue depth and consumer lag
  • garbage collection pauses, thread or event loop saturation
  • cache hit ratio and upstream error rates

The pattern you are looking for is the first resource to saturate as load rises. That is your bottleneck, and fixing anything else first is wasted effort. Annotate dashboards with test start and stop times, or stream k6 results into the same observability stack, so you can line the curves up.

Sanity-check concurrency with Little's law

Little's law relates three quantities in any stable system: the average number of items in the system equals the arrival rate multiplied by the average time each item spends there.

concurrency = arrival rate × time in system

200 requests/s × 0.25 s  = 50 requests in flight
200 iterations/s × 1.5 s = 300 VUs needed

This is useful in two directions. Before a test, it tells you roughly how many VUs to pre-allocate for an arrival-rate scenario: target iterations per second multiplied by expected iteration duration, plus headroom for when things slow down. After a test, it lets you check results for consistency. If k6 reports 200 requests per second at 250 ms but your application shows 400 concurrent requests, something is queuing that you have not accounted for, or the measurements disagree about what they are measuring.

It also explains why slowdowns snowball. If latency doubles at a constant arrival rate, concurrency doubles too, which can exhaust a connection pool that was comfortably sized a moment earlier.

Closing

A useful load test is a small experiment: a stated target, a realistic open-model workload, thresholds that can fail, a generator you trust, and server-side metrics that explain the result. Get those right and the numbers start to mean something. Repeat the same tests after every significant change and you will catch regressions before your users do.

Setting targets, modelling workload and running the full range of k6 test types safely is covered in depth in the Scale & Load Engineer pack.

More articles