In 30 seconds
Load testing sends realistic traffic to your system to measure speed and find its limits. k6 is an open-source load testing tool by Grafana Labs: you write tests in JavaScript or TypeScript, run them from the command line, and set pass/fail thresholds such as “95% of requests under 400 ms”. The current major line is k6 2.x, and it fits naturally into CI pipelines.
Not in my daily stack — I know the concepts and would learn the details when a project needs it.
Why it matters
- Functional tests prove the code works for one user. Load tests prove it still works for a thousand at once, which is where connection pools, locks and slow queries show up.
- They turn capacity planning into numbers: how many requests per second one server handles, and where the next bottleneck is.
- Teams run them before launches, campaigns and big refactors, so a sale or a marketing email does not become an outage.
- They complement unit and integration tests (PHPUnit, Jest): those catch wrong behaviour, load tests catch slow and fragile behaviour.
How it works
01Types of performance tests
“Load test” is used loosely, but each type answers a different question. A smoke test runs a tiny load to check the script and system work. An average-load test simulates normal traffic to confirm you meet targets. A stress test pushes beyond normal to see how the system degrades. A spike test jumps suddenly, like a push notification. A soak (endurance) test holds normal load for hours to find memory leaks and slow resource exhaustion. A breakpoint test increases load until something breaks, to find the real capacity. Start with smoke and average load; add others when you know your baseline.
- Throughput: requests or iterations per second the system completes.
- Latency percentiles: p95 means 95% of requests were faster than this value.
- Error rate: share of failed requests, often the first thing to break.
02Your first k6 script
A k6 script is an ES module. The options export describes the load and the pass/fail rules; the default function is what each virtual user (VU) runs in a loop. A VU is a simulated user running your function concurrently with others. check() validates responses without stopping the test; thresholds turn metrics into a pass or fail result, and k6 exits with a non-zero code when a threshold fails, which is what breaks a CI build. sleep() adds think time so a VU behaves like a real person, not a tight loop. k6 runs TypeScript files directly, so you can use types in larger suites.
import http from "k6/http";
import { check, sleep } from "k6";
export const options = {
vus: 20,
duration: "2m",
thresholds: {
http_req_failed: ["rate<0.01"], // under 1% errors
http_req_duration: ["p(95)<400", "p(99)<1000"], // ms
},
};
export default function () {
const res = http.get("https://staging.example.com/api/products?page=1");
check(res, { "status is 200": (r) => r.status === 200 });
sleep(1);
}
03Scenarios and executors: open vs closed models
Scenarios let you shape load precisely and run several patterns at once. Executors decide how. Closed-model executors (constant-vus, ramping-vus) keep a number of VUs busy; if the system slows down, each VU makes fewer requests, which hides the problem. Open-model executors (constant-arrival-rate, ramping-arrival-rate) start new iterations at a fixed rate regardless of response time, which matches real internet traffic: users keep arriving even when your site is slow. For API capacity tests, prefer arrival-rate executors and give them enough preAllocatedVUs and maxVUs to sustain the rate.
export const options = {
scenarios: {
browse: {
executor: "ramping-arrival-rate",
startRate: 10, timeUnit: "1s",
preAllocatedVUs: 50, maxVUs: 300,
stages: [
{ target: 100, duration: "3m" }, // ramp to 100 requests/s
{ target: 100, duration: "10m" }, // hold
{ target: 0, duration: "2m" }, // ramp down
],
},
},
thresholds: { "http_req_duration{scenario:browse}": ["p(95)<500"] },
};
04Realistic user journeys
Real users log in, browse, search and check out, with pauses between steps. Model the main journeys with their real proportions from analytics (for example 80% browse, 15% search, 5% checkout). Use group() to name steps, tags to slice metrics, and SharedArray to load test data once and share it between VUs without copying memory. Generate unique data per VU and iteration where writes happen, so you do not hit the same row and create artificial lock contention. Do the setup (create a test account, get a token) in setup(), which runs once, and pass the result to VUs.
import http from "k6/http";
import { group, check } from "k6";
import { SharedArray } from "k6/data";
const products = new SharedArray("products", () => JSON.parse(open("./products.json")));
const BASE = __ENV.BASE_URL || "https://staging.example.com";
export function setup() {
const res = http.post(BASE + "/api/login", JSON.stringify({ email: "[email protected]", password: __ENV.TEST_PASSWORD }),
{ headers: { "Content-Type": "application/json" } });
return { token: res.json("token") };
}
export default function (data) {
const params = { headers: { Authorization: "Bearer " + data.token } };
group("product page", () => {
const p = products[Math.floor(Math.random() * products.length)];
check(http.get(BASE + "/api/products/" + p.slug, params), { "200": (r) => r.status === 200 });
});
}
05Running, reading results and CI
Run k6 run script.js locally or in a container. At the end, k6 prints a summary: request rate, latency percentiles, error rate, data transferred and whether each threshold passed. Look at p95 and p99, not averages, because averages hide the slow tail that users notice. For trends and correlation with server metrics, stream results to Prometheus (remote write) or Grafana Cloud k6 and view them next to CPU, database and queue dashboards. In CI, run a short smoke or average-load test on every release against staging, and keep longer stress and soak tests on a schedule. Never load test production or a third-party API without explicit permission and a plan.
# Local run with an environment variable
k6 run -e BASE_URL=https://staging.example.com script.js
# Docker, no local install
docker run --rm -i grafana/k6 run - <script.js
# Stream metrics to Prometheus remote write
K6_PROMETHEUS_RW_SERVER_URL=http://localhost:9090/api/v1/write \
k6 run -o experimental-prometheus-rw script.js
Use it when
- Before launches, sales and campaigns, to confirm the system meets its latency and error targets.
- After major changes (new framework version, database migration, caching layer) to catch performance regressions.
- For capacity planning: deciding instance sizes, autoscaling thresholds and connection pool limits.
- To reproduce and fix production slowdowns in a safe environment.
Skip it when
- As a replacement for profiling a single slow request; use APM traces or a profiler first.
- Against production or third-party services without permission, a time window and a rollback plan.
- On a staging environment so different from production that the numbers mean nothing.
Alternatives
- Apache JMeter
Mature Java tool with a GUI and many protocols; heavier, XML-based test plans.
- Gatling
Code-based tests in Java, Kotlin or Scala (and JavaScript), efficient engine and good reports.
- Locust
Python-based, write user behaviour as Python classes; easy for Python teams.
- Artillery
YAML and JavaScript tests, Node.js based, with Playwright integration for browser load.
Best practices
- Define success first: target throughput, p95 and p99 latency and error rate, written as thresholds.
- Use arrival-rate executors for capacity tests so slow responses do not hide problems.
- Model real journeys and traffic mix from analytics, with think time and varied test data.
- Test in an environment that mirrors production in size, configuration and data volume.
- Monitor the system under test (CPU, memory, database, queues) during every run, not only k6 output.
- Run a short smoke test in CI on each release and heavier tests on a schedule.
- Make sure the load generator itself is not the bottleneck: watch its CPU and network.
Common mistakes
- Reading average response time instead of p95 and p99, which hides the slow experience.
- Hammering one URL with no think time or data variety, so caches make everything look fast.
- Load testing from a laptop over home Wi-Fi and measuring the network instead of the server.
- Forgetting test data cleanup, leaving thousands of fake orders or users in a shared environment.
- Running against production without warning anyone, triggering alerts, rate limits or costs.
Deep dive
06Little's law and sizing a test
Little's law links three numbers: concurrency = throughput × time in system. If you need 200 requests per second and a request takes 0.25 seconds, the system holds about 50 requests in flight. For k6 arrival-rate tests, the VUs needed ≈ rate × iteration duration (including sleeps), so a 2-second journey at 50 iterations per second needs about 100 VUs; set maxVUs with headroom. The same law explains server limits: a PHP-FPM pool of 40 workers with 200 ms requests caps out near 200 requests per second, and anything above that queues. If latency jumps while throughput flattens, you have hit a concurrency limit somewhere: worker pool, database connections or a lock.
07Finding the bottleneck during a test
k6 tells you that something is slow; server metrics tell you why. Watch, in order: application CPU and worker saturation, database CPU, slow query log and connections, cache hit ratio, queue depth, and external API latency. Use the USE method (utilisation, saturation, errors) for each resource. Tag requests in k6 by endpoint name, so URLs with IDs are grouped (use the name tag), and correlate the slowest endpoints with traces in your APM. Fix one bottleneck at a time and rerun the same test; the next bottleneck usually appears immediately. Keep a results log with the commit, configuration and key numbers for each run, so you can compare honestly.
// Group dynamic URLs under one metric name
http.get(BASE + "/api/products/" + id, { tags: { name: "GET /api/products/:id" } });
// Custom metric for a business step
import { Trend } from "k6/metrics";
const checkoutTime = new Trend("checkout_duration", true);
checkoutTime.add(res.timings.duration);
08Coordinated omission and generator limits
Coordinated omission happens when the load generator waits for slow responses before sending the next request, so it sends fewer requests exactly when the system is struggling, and the measured latency looks better than reality. Closed-model executors suffer from it by design. Arrival-rate executors avoid most of it by starting iterations on schedule, but if they run out of VUs, k6 reports dropped_iterations, which you must treat as a failure signal. Also watch the generator: if its CPU is above roughly 80%, timings become unreliable. For large tests, distribute load across several machines with the k6 Operator on Kubernetes or Grafana Cloud k6, and generate load from the same region as users.
09Testing beyond HTTP: WebSockets, gRPC and extensions
k6 has built-in modules for HTTP/1.1 and HTTP/2, WebSockets, gRPC and the browser, and extensions (built with xk6, and in newer versions resolved automatically for official ones) add protocols such as Kafka, SQL or Redis. This lets you test the parts of a system that HTTP cannot reach: a WebSocket chat under thousands of connections, a gRPC service mesh, or a queue consumer's throughput. Treat these tests like the HTTP ones: realistic message rates, thresholds on latency and errors, and server-side monitoring. For event-driven systems, measure end-to-end time from publish to effect, not only the publish call.
Real-world scenarios
Preparing for a Black Friday sale
Problem
An online store expects ten times its normal traffic for a sale. The team does not know whether the current servers, database and payment flow will hold.
How to solve it
Pull last year's peak and traffic mix from analytics, multiply by the expected factor, and define thresholds: p95 under 800 ms for product pages, under 1.5 s for checkout, errors under 1%. Write scenarios for browse, search, cart and checkout with a ramping-arrival-rate executor and a stubbed payment provider sandbox. Run against a production-sized staging environment with a copy of anonymised data. Fix the first bottleneck (often database queries or connection pools), rerun, and repeat until targets pass with 30% headroom. Finish with a spike test and a 2-hour soak, and agree on a scaling and rollback plan.
Latency regression after an upgrade
Problem
After a framework and PHP upgrade, users report the API feels slower, but nothing obvious appears in error logs.
How to solve it
Write a repeatable average-load test for the ten busiest endpoints with fixed data and a constant arrival rate. Run it against the previous release and the new one on identical infrastructure, three times each, and compare p95 per endpoint using name tags. Look at the endpoints with the largest change, and use APM traces or a profiler on them. Common causes are disabled OPcache or caching, N+1 queries introduced by a dependency, or changed connection settings. After the fix, add a threshold-based smoke test to CI so the next regression fails the pipeline instead of reaching users.
Load test shows great numbers, production does not
Problem
k6 reports p95 of 80 ms at 500 requests per second, but production slows down at 200 requests per second.
How to solve it
The test is probably unrealistic. Check whether all VUs hit the same few URLs, which stay hot in cache, or reuse one user account. Add varied test data from a SharedArray, real traffic proportions and authenticated flows. Compare staging and production: database size, indexes, cache configuration, CDN and number of workers. Use an arrival-rate executor and check dropped_iterations. Include background load that exists in production, such as cron jobs and queue workers. Then compare the test's per-endpoint latency with production metrics; when they match at low load, the model is trustworthy for higher loads.
Cheat sheet
- Run a test
k6 run script.js- Override VUs and duration
k6 run --vus 50 --duration 5m script.js- Pass an environment variable
k6 run -e BASE_URL=https://staging.example.com script.js- Error-rate threshold
http_req_failed: ["rate<0.01"]- Latency threshold
http_req_duration: ["p(95)<400", "p(99)<1000"]- Stop early on failure
{ threshold: "rate<0.05", abortOnFail: true }- Group dynamic URLs
http.get(url, { tags: { name: "GET /api/items/:id" } })- Export summary as JSON
k6 run --summary-export=summary.json script.js- Run in Docker
docker run --rm -i grafana/k6 run - <script.js
Questions & answers
Q1What is the difference between load, stress and soak testing?
A load test checks the system under expected traffic, such as a normal day or a planned peak, to confirm it meets latency and error targets. A stress test goes beyond expected traffic to see how and where the system degrades, and whether it recovers when load drops. A soak test keeps normal load for hours to reveal slow problems like memory leaks, connection leaks, growing queues or disk filling up. Spike tests add a sudden jump, and breakpoint tests ramp until failure to find maximum capacity. Each answers a different question, so I pick based on the risk I want to reduce.
Q2Why look at p95 and p99 instead of the average?
Averages hide the tail. If 90 requests take 100 ms and 10 take 3 seconds, the average is about 390 ms, which looks acceptable, but one in ten users waits three seconds. p95 says 95% of requests were faster than this value; p99 shows the worst experience most users will still meet. A page often makes many requests, so the chance that a page view hits the slow tail is much higher than for one request. Targets and alerts should therefore be on percentiles, together with error rate, and averages are only useful for capacity math.
Q3What is a virtual user in k6?
A virtual user, or VU, is an independent execution context that runs your default function in a loop, like a simulated person using the system. Each VU has its own variables and cookie jar, and VUs run concurrently. The number of VUs is not the same as requests per second: if each iteration takes two seconds including think time, 100 VUs produce about 50 iterations per second. That is why arrival-rate executors exist: you specify the rate of new iterations and k6 allocates as many VUs as needed. k6 runs VUs efficiently in Go, so one machine can simulate thousands.
Q4How do you use thresholds in CI?
Thresholds are pass/fail rules on metrics, for example http_req_failed rate under 1% and http_req_duration p95 under 400 milliseconds. When any threshold fails, k6 exits with a non-zero code, so a CI job fails just like a broken unit test. You can scope thresholds to tags, such as only the checkout scenario, and set abortOnFail to stop a test early if errors explode. In a pipeline I run a short test against staging after deployment, with thresholds based on the service's objectives, and keep longer tests scheduled nightly so the main pipeline stays fast.
Q5How do you make a load test realistic?
Start with production data: analytics or access logs show which endpoints are used, in what proportions, and at what peak rate. Model the main user journeys with think time between steps, and use varied test data such as different products, search terms and users, so caches and database behave as in reality. Use an open arrival-rate model so users keep arriving even if the system slows. Run against an environment close to production in size and data volume, include warm-up, and monitor the servers during the test. Finally, compare the test's latency profile with production to validate the model.
Q6Can k6 test the browser and frontend performance?
Yes. The k6 browser module drives a real Chromium browser with an API similar to Playwright, so you can measure Core Web Vitals such as LCP and INP and test user journeys in the browser. Browser tests are much heavier than protocol tests: one browser VU uses as much resource as hundreds of HTTP VUs. The common hybrid approach is to generate most load with lightweight HTTP scenarios and run a handful of browser VUs in parallel to measure what real users experience under that load. For pure frontend performance without load, Lighthouse or WebPageTest are simpler tools.
Senior-level questions
S1How would you build performance testing into a delivery pipeline?
I would use layers with different costs. On every merge: a one or two minute smoke test against an ephemeral or staging environment, with thresholds on error rate and p95 for key endpoints; it catches broken scripts and large regressions. Nightly: an average-load test at expected peak with a fixed data set on a production-like environment, results stored in Prometheus or Grafana Cloud k6 and compared with the previous runs, alerting on regressions of more than about 10%. Before big launches: stress, spike and soak tests, with engineers watching. Scripts live in the repository next to the code, reviewed like tests. The trade-off is environment cost versus confidence; ephemeral environments help but must be sized realistically or the numbers lie.
S2How do you turn load test results into capacity and autoscaling decisions?
Run a stepped arrival-rate test and plot throughput, p95 latency and resource utilisation per step. The knee of the curve, where latency rises sharply while throughput flattens, is the practical capacity of one instance; I plan to run at around 60–70% of it to absorb spikes and failures. Identify which resource saturates first: if it is CPU, autoscale on CPU; if it is database connections, adding app instances makes things worse, so fix pooling or add read replicas first. Test the autoscaler itself with a spike test, since scaling takes minutes and the spike may be over by then. Finally, compute the cost per thousand requests at the chosen sizing, which makes the trade-off visible to the business.
S3What makes load testing an event-driven or queue-based system different?
The HTTP response is no longer the end of the work. An API may answer 202 Accepted in 30 ms while the job sits in a queue for minutes, so request latency alone looks great while the system is falling behind. I measure end-to-end: tag each test message with an ID and timestamp, and have the consumer or a probe record completion time, then track lag and queue depth during the test. Thresholds should cover both the API and the processing time objective, for example 95% of orders processed within 30 seconds. I also test consumer scaling and back-pressure: what happens when the downstream service slows down, and whether retries amplify load. Soak tests matter more here, because backlogs grow slowly.
Glossary
- Virtual user (VU)
- A simulated user running the test function in a loop, concurrently with others.
- Iteration
- One complete run of the default function by a VU.
- Executor
- The k6 component that schedules VUs and iterations in a scenario.
- Arrival rate
- Starting new iterations at a fixed rate, independent of response time (open model).
- Threshold
- A pass/fail rule on a metric; failing one makes k6 exit with an error.
- Check
- An assertion on a response that is counted but does not stop the test.
- p95 / p99
- The latency below which 95% or 99% of requests completed.
- Soak test
- A long test at normal load to find leaks and slow resource exhaustion.
- Coordinated omission
- A measurement error where the tester slows down with the system and hides real latency.
Keep learning
Have a product in mind?Let's build it.
Tell me about your idea, your product or your team. I usually reply within one working day.