Load testing reveals performance problems before your users discover them. The difference between a load test that finds real issues and one that gives false confidence comes down to the quality of the test scripts. A poorly designed test uses unrealistic data, ignores think times, and sends all requests simultaneously.
This tells you that your system handles 10,000 requests per second when in reality it falls over at 100 concurrent users. k6 is a modern load testing tool that makes writing realistic scripts straightforward, with native support for JavaScript scripting and a command-line interface that fits naturally into development workflows.
This guide covers how to write k6 load test scripts that produce accurate results, how to interpret the output, and how to design tests that target the specific performance questions you need answered. Whether you are setting up load testing for the first time or improving existing scripts that have not been giving you reliable results, the principles here will help you get more value from your testing efforts.
Why Most Load Tests Miss Real Problems
A load test that does not reflect real user behaviour will not find the problems that real users encounter. The most common mistakes made when writing load test scripts are:
- Ignoring think times: Users do not send requests continuously. They read content, make decisions, and respond. Sending requests back-to-back without realistic pauses dramatically overestimates system capacity.
- Unrealistic data distributions: A query against a warm cache behaves very differently from a query against a cold database. If your test data does not reflect the real mix of cached and uncached requests, your results will be misleading.
- No session handling: Real users log in, maintain sessions across requests, and make authenticated calls. Tests that bypass authentication miss the performance impact of session management entirely.
- Testing only the happy path: Focusing exclusively on ideal request patterns ignores the slow queries, edge cases, and error conditions that cause most production incidents.
- Synchronous request patterns: Sending all virtual users through the exact same flow at the exact same time creates artificial load patterns that never occur in production.
k6's scripting model makes it easier to write realistic tests because it runs actual JavaScript that describes user behaviour rather than a configuration file that describes request patterns. This means you can model complex user flows with session state, conditional logic, and realistic think times. The scripts you write for k6 are the same JavaScript skills your development team already uses, which lowers the barrier to maintaining and improving tests over time.
For teams working on deployment automation, combining load testing with proper deployment scripts helps ensure performance is validated as part of your release process. Writing basic scripts that deploy and then immediately run load tests creates a repeatable performance validation step in your pipeline.
Setting Up k6
k6 is installed as a single binary and runs from the command line. There is no Java runtime, no GUI, and no separate controller or agent architecture. You write test scripts in JavaScript, run them from the terminal, and get structured output that you can pipe to other tools or review directly.
# Install on macOS
brew install k6
# Install on Linux (Debian/Ubuntu)
sudo gpg -k
sudo gpg --no-default-keyring --keyring /usr/share/keyrings/k6-archive-keyring.gpg --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D7D4977E34125D7D7C3D9C1D6AC1D6A2C
echo "deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main" | sudo tee /etc/apt/sources.list.d/k6.list
sudo apt update
sudo apt install k6
# Run a basic test
k6 run --vus 10 --duration 30s script.js
For results aggregation and visualisation, k6 integrates with Grafana, InfluxDB, and k6 Cloud. The open-source combination of k6 with Grafana and InfluxDB provides a capable stack for most teams without requiring external services. The Grafana k6 plugin includes pre-built dashboards designed specifically for k6 output, giving you immediate visibility into your test results.
If you are already using bash scripts for application deployment, adding a k6 load test to the end of your deployment pipeline is straightforward. Your existing scripting knowledge translates directly to writing effective load tests.
Writing a Realistic k6 Script
A realistic k6 script models an actual user session: the requests they make, the time between requests, and the data they submit. The goal is to simulate the patterns your real users follow, not to maximise request throughput artificially.
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
vus: 50,
duration: '5m',
thresholds: {
http_req_duration: ['p(95)<500'],
http_req_failed: ['rate<0.01'],
},
};
export default function () {
// Homepage visit
let res = http.get('https://yourapp.com/');
check(res, {
'homepage returns 200': (r) => r.status === 200,
'homepage loads under 1s': (r) => r.timings.duration < 1000,
});
// Browse products with realistic think time
let productsRes = http.get('https://yourapp.com/products');
check(productsRes, {
'products page loaded': (r) => r.status === 200,
});
sleep(Math.random() * 3 + 1); // Random think time between 1-4 seconds
// View specific product detail
let productId = Math.floor(Math.random() * 1000) + 1;
let detailRes = http.get(`https://yourapp.com/products/${productId}`);
check(detailRes, {
'product detail loaded': (r) => r.status === 200,
});
sleep(Math.random() * 2 + 1); // Another think time pause
// Add to cart with POST request
let cartRes = http.post(
'https://yourapp.com/cart/add',
JSON.stringify({ product_id: productId, quantity: 1 }),
{
headers: { 'Content-Type': 'application/json' },
}
);
check(cartRes, {
'added to cart successfully': (r) => r.status === 200,
});
}
The script models a real user journey: visiting the homepage, browsing product listings, viewing a specific product, and adding it to the cart. The random sleep times between requests reflect actual user behaviour. Varying the product ID across requests ensures your database queries hit different records rather than being artificially optimised by caching a single query.
The check function validates response conditions and records pass or fail counts for each check. The thresholds configuration in the options defines your performance expectations. In this example, you are saying that 95 percent of requests must complete within 500 milliseconds and the failure rate must stay below 1 percent. When a threshold fails, it means your system is not meeting your defined performance criteria, not just that you pushed enough load to break something.
Handling Authentication in Load Tests
Authenticated endpoints require session handling in load tests. If you only test public pages, you miss the performance characteristics of your authentication system and any endpoints that require logged-in context.
Use k6's cookies object or a per-VU session token to maintain authentication across requests. Each virtual user should establish their own session rather than sharing credentials, because shared authentication can mask session content issues that appear in production.
import http from 'k6/http';
import { check } from 'k6';
export const options = {
vus: 20,
duration: '3m',
};
export default function () {
// Log in once per virtual user at the start of their session
let loginRes = http.post(
'https://yourapp.com/login',
JSON.stringify({
username: `testuser_${__VU}@example.com`,
password: 'testpassword123',
}),
{
headers: { 'Content-Type': 'application/json' },
}
);
check(loginRes, {
'login successful': (r) => r.status === 200,
});
// Extract session cookie from login response
let sessionCookie = loginRes.cookies['session_id']?.[0]?.value;
if (sessionCookie) {
// Use session for authenticated requests
let dashboardRes = http.get('https://yourapp.com/dashboard', {
cookies: {
session_id: sessionCookie,
},
});
check(dashboardRes, {
'dashboard accessible': (r) => r.status === 200,
'dashboard loads content': (r) => r.body.length > 100,
});
// Make additional authenticated API calls
let profileRes = http.get('https://yourapp.com/api/profile', {
cookies: {
session_id: sessionCookie,
},
});
check(profileRes, { 'profile loads': (r) => r.status === 200 });
}
}
For more realistic load testing with authentication, use a range of test credentials rather than a single shared credential. The __VU built-in variable gives each virtual user a unique number, so you can create distinct user sessions across your test. This reflects the real distribution of users across your system and can expose session store content that single-user testing misses.
For APIs that use Bearer tokens or API keys, include the authentication header with each request rather than managing cookies. For JWT-based authentication, you can generate signed tokens at runtime within your k6 script using a library like jose, which is useful for testing microservices that use JWT for service-to-service authentication.
Reading k6 Output: Understanding the Metrics
k6 outputs several key metrics that tell you about system performance under load. Understanding what each metric represents helps you interpret results correctly and identify real problems.
http_req_duration measures the end-to-end duration of HTTP requests from the client perspective. This includes DNS lookup, connection time, TLS handshake, time to first byte, and content download. The p95 and p99 percentiles are more useful than the average. If your p99 response time is 2 seconds while your average is 200 milliseconds, most users have a good experience but a significant tail is suffering slow responses.
http_req_failed reports the percentage of requests that returned non-2xx responses. A failed request rate above 1 percent during a load test warrants investigation before you proceed. Distinguish between connection failures (network or server down), timeout failures (server too slow), and application errors (5xx responses from your code).
vus reports the number of concurrent simulated users. Increasing vus while keeping response times acceptable tells you your system's concurrency capacity. Track how response times change as you increase vus to find the point where performance degrades.
iterations counts complete user sessions executed. The iteration rate (iterations per second) multiplied by the average session value tells you your system's effective throughput under load. For an e-commerce site, this might be orders per hour. For an API, it might be requests per second.
http_req_failed counts individual HTTP requests made, which is different from iterations because a single user session contains multiple requests. This metric helps you understand total request volume independent of session structure.
checks reports the pass and fail counts for your validation checks. High check failure rates alongside low http_req_failed rates indicate valid responses that do not contain expected content, which might point to rendering issues or API contract changes.
A well-configured k6 run produces output like this:
running (2m30s), 0/50 VUs, 1247 complete and 0 interrupted iterations
default ✓ homepage returns 200
default ✓ homepage loads under 1s
default ✓ products page loaded
default ✓ product detail loaded
default ✓ added to cart successfully
████████████████████████████████████████ 100%
data_received............45 MB 302 kB/s
data_sent................3.2 MB 21 kB/s
http_req_blocked.........avg=1.2ms min=0s med=1ms max=89ms p(90)=2ms p(95)=3ms
http_req_connecting.......avg=0s min=0s med=0s max=0s p(90)=0s p(95)=0s
http_req_duration.........avg=142ms min=89ms med=134ms max=892ms p(90)=198ms p(95)=245ms
http_req_failed...........0.00%
http_req_receiving.........avg=12ms min=2ms med=10ms max=89ms p(90)=18ms p(95)=24ms
http_req_sending...........avg=1ms min=0s med=0s max=8ms p(90)=1ms p(95)=2ms
http_req_tls_handshaking...avg=0s min=0s med=0s max=0s p(90)=0s p(95)=0s
http_req_waiting..........avg=129ms min=75ms med=121ms max=820ms p(90)=198ms p(95)=221ms
iterations................1247 8.316095/s
This output shows a healthy test run: p95 response time of 245 milliseconds, zero failed requests, and consistent iteration rate. The http_req_duration breakdown shows where time is spent: most time is in the waiting phase (server processing), with minimal time in sending and receiving phases.
Designing Tests That Answer Specific Questions
Before writing a load test, clarify what question you are trying to answer. Different questions require different test designs. "What is our maximum throughput?" requires a different approach from "What is our response time at 1,000 concurrent users?" or "Does database query performance degrade under sustained load?"
For capacity planning, run a test that ramps vus gradually and tracks when response times exceed your SLA threshold. The vus count at which response times become unacceptable is your effective capacity. Run this test regularly to track how capacity changes as your data grows or as you deploy new code.
export const options = {
stages: [
{ duration: '2m', target: 100 }, // Ramp to 100 users
{ duration: '5m', target: 100 }, // Hold at 100 users
{ duration: '2m', target: 200 }, // Ramp to 200 users
{ duration: '5m', target: 200 }, // Hold at 200 users
{ duration: '2m', target: 0 }, // Ramp down
],
thresholds: {
http_req_duration: ['p(95)<1000'], // SLA: p95 must be under 1 second
},
};
For regression testing, save your test results and compare them against a baseline. If the same test script running against the same infrastructure produces significantly worse results after a code deployment, that change has introduced a performance regression that needs investigation. k6's JSON output makes automated regression comparison straightforward.
For CDN and caching validation, design tests that specifically exercise your caching layer. If you use Cloudflare or a similar CDN, your load test should measure cache hit rates and compare response times for cached versus origin requests. A CDN that is not properly configured may serve all requests from origin, eliminating the performance benefit you are paying for.
Environment Isolation and Test Data
Load tests must run against an isolated environment separate from production. A load test against your production database will corrupt production data or trigger real alerts, potentially affecting real users. Use a dedicated testing environment that mirrors production configuration, or use a database snapshot that you refresh before each test run.
Test data must be realistic in both volume and distribution. If your production database has 10 million records and your test database has 10,000, query performance will be significantly different. Cache behaviour changes with data volume. Use sanitised production data samples for load testing rather than synthetic data that does not reflect real data distributions.
Consider the full stack in your test environment. A load test against a scaled-down test environment that uses a fraction of the production infrastructure will not give you accurate results. Your test environment should mirror production as closely as possible, including load balancer configuration, auto-scaling settings, and database sizing.
For API testing, ensure your test environment uses realistic third-party service stubs. If your application calls external payment providers, email services, or other APIs, your test environment should either use mock services or sandbox endpoints that can handle your test load without rate limiting or charges.
Running Load Tests as Part of Regular Maintenance
Load testing is most valuable when run regularly rather than only before major releases. Performance can degrade gradually as data grows, as dependencies change, or as usage patterns shift. A baseline test run during development helps you catch regressions before they reach production.
Automating load tests in your deployment pipeline provides consistent performance validation. If a deployment causes a measurable performance regression, automated tests can block the deployment or alert your team before the change affects users. This approach works well alongside infrastructure monitoring and regular server maintenance support.
Consider scheduling baseline load tests to run on a regular cadence: weekly for high-traffic applications, monthly for stable applications. Compare results over time to identify gradual degradation before it becomes a problem. Store test results in a version control system or results database so you have historical data to reference when investigating incidents.
When to Get Help with Load Testing
If you are setting up load testing for the first time and want validation that your scripts are realistic, an experienced IT specialist can review your test design and suggest improvements. If your load tests consistently show problems you cannot reproduce in development, or if performance issues only appear under load, professional debugging help can identify root causes faster than trial and error.
For businesses running critical web applications, integrating load testing into regular technical support and maintenance routines helps catch performance issues before they become user-facing problems. Whether you handle this internally or work with an external specialist depends on your team's existing expertise and the complexity of your infrastructure.
If you want a practical review of your current testing setup, you can get in touch with details of your application architecture, current performance concerns, and what you want your load tests to validate.
Related practical reading
These related guides can help you connect this topic with the wider website, server, security, and support decisions around it.
- SSH Config Tips That Save Hours of Time - useful background for related technology decisions