k6 is an open source load testing tool from Grafana Labs. You describe user behaviour in a JavaScript file, and k6 runs it with many concurrent virtual users (VUs) while measuring response times, error rates and throughput. In this tutorial you will install k6 on Ubuntu 24.04, write a test with checks, ramp the load up and down, define thresholds that make the test pass or fail, model traffic as requests per second and export an HTML report.
Prerequisites
To follow this guide you need:
- A server running Ubuntu 24.04 LTS to run k6 from, such as a CubePath VPS. Use a different machine from the one you are testing, so k6 does not steal CPU from the application.
- A non-root user with
sudoprivileges. - A web application or API that you own or have permission to test, reachable at
https://your_domain. Replaceyour_domainwith your own hostname throughout the guide.
WarningOnly load test systems you are responsible for. Generating heavy traffic against third-party sites can break their terms of service and trigger abuse protection.
Step 1 - Installing k6 from the official repository
Grafana publishes k6 in its own APT repository. Import the signing key into a dedicated keyring:
sudo gpg -k
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/k6-archive-keyring.gpg \
--keyserver hkp://keyserver.ubuntu.com:80 \
--recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69
The first command only initialises root's GnuPG directory so the import can run. Now add the repository, referencing that keyring:
echo "deb [signed-by=/etc/apt/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main" \
| sudo tee /etc/apt/sources.list.d/k6.list
Install k6:
sudo apt update
sudo apt install k6
Verify the installation:
k6 version
k6 v1.3.0 (go1.24.6, linux/amd64)
Step 2 - Writing and running a first test
Create a working directory for your scripts:
mkdir -p ~/k6-tests
cd ~/k6-tests
nano smoke.js
A k6 script exports a default function. Each VU runs that function in a loop for the whole test. This script requests the home page, checks the response and pauses for one second, like a real visitor would:
import http from 'k6/http';
import { check, sleep } from 'k6';
const BASE_URL = __ENV.BASE_URL || 'https://your_domain';
export default function () {
const res = http.get(`${BASE_URL}/`);
check(res, {
'status is 200': (r) => r.status === 200,
'body is not empty': (r) => r.body && r.body.length > 0,
});
sleep(1);
}
__ENV.BASE_URL reads a value passed with -e on the command line, so the same script can target staging or production. Run a short smoke test with one VU for 10 seconds:
k6 run -e BASE_URL=https://your_domain --vus 1 --duration 10s smoke.js
At the end k6 prints a summary. The most important lines are:
checks_succeeded...................: 100.00% 20 out of 20
checks_failed......................: 0.00% 0 out of 20
http_req_duration..................: avg=48.21ms min=31.02ms med=45.10ms max=112.77ms p(90)=62.40ms p(95)=71.88ms
http_req_failed....................: 0.00% 0 out of 10
http_reqs..........................: 10 0.98/s
iterations.........................: 10 0.98/s
vus................................: 1 min=1 max=1
http_req_durationis the full response time. Focus onp(95): 95% of requests were faster than this.http_req_failedis the share of requests that returned an error status or failed to connect.checks_succeededshows how many of yourcheck()assertions passed.
A smoke test confirms the script works before you apply real load.
Step 3 - Ramping load up and down
Real traffic grows gradually. The stages option changes the number of VUs over time: k6 moves linearly from the current value to each target during its duration. Create a new script:
nano load.js
import http from 'k6/http';
import { check, sleep } from 'k6';
const BASE_URL = __ENV.BASE_URL || 'https://your_domain';
export const options = {
stages: [
{ duration: '1m', target: 20 }, // ramp up to 20 VUs
{ duration: '3m', target: 20 }, // stay at 20 VUs
{ duration: '1m', target: 50 }, // push to 50 VUs
{ duration: '2m', target: 50 }, // stay at 50 VUs
{ duration: '1m', target: 0 }, // ramp down
],
};
export default function () {
const home = http.get(`${BASE_URL}/`, { tags: { name: 'home' } });
check(home, { 'home 200': (r) => r.status === 200 });
sleep(1);
const api = http.get(`${BASE_URL}/api/products`, { tags: { name: 'products' } });
check(api, {
'products 200': (r) => r.status === 200,
'products is JSON': (r) => (r.headers['Content-Type'] || '').includes('application/json'),
});
sleep(2);
}
Change /api/products to an endpoint that exists in your application. The name tag groups results per endpoint, which you will use for thresholds in the next step. Run the test:
k6 run -e BASE_URL=https://your_domain load.js
While it runs, watch CPU, memory and error logs on the application server. Compare http_req_duration in the 20 VU and 50 VU phases: if the p95 grows sharply when VUs increase, you are close to the application's capacity.
Step 4 - Defining thresholds
Thresholds turn a load test into a pass/fail check. If any threshold is not met, k6 marks it as failed and exits with a non-zero status code, which makes k6 suitable for CI pipelines. Add a thresholds block to the options in load.js:
export const options = {
stages: [
{ duration: '1m', target: 20 },
{ duration: '3m', target: 20 },
{ duration: '1m', target: 50 },
{ duration: '2m', target: 50 },
{ duration: '1m', target: 0 },
],
thresholds: {
http_req_failed: ['rate<0.01'], // less than 1% errors
http_req_duration: ['p(95)<500', 'p(99)<1000'], // global latency budget
'http_req_duration{name:products}': ['p(95)<300'], // stricter for the API
checks: ['rate>0.99'], // 99% of checks pass
},
};
Run the test again. At the top of the summary, k6 lists each threshold with a check mark or a cross:
THRESHOLDS
checks
✓ 'rate>0.99' rate=100.00%
http_req_duration
✓ 'p(95)<500' p(95)=187.42ms
✓ 'p(99)<1000' p(99)=402.10ms
{name:products}
✗ 'p(95)<300' p(95)=341.77ms
http_req_failed
✓ 'rate<0.01' rate=0.00%
Check the exit code:
echo $?
99
Exit code 99 means at least one threshold failed; 0 means all passed. To stop a test early as soon as a threshold is crossed, use the object form, for example http_req_failed: [{ threshold: 'rate<0.05', abortOnFail: true }].
Step 5 - Modelling requests per second with scenarios
With VU-based tests, a slower application receives fewer requests, because each VU waits for the response. To test a fixed request rate regardless of response time, use a scenario with an arrival-rate executor:
nano rate.js
import http from 'k6/http';
import { check } from 'k6';
const BASE_URL = __ENV.BASE_URL || 'https://your_domain';
export const options = {
scenarios: {
steady_api: {
executor: 'constant-arrival-rate',
rate: 100, // 100 iterations...
timeUnit: '1s', // ...per second
duration: '3m',
preAllocatedVUs: 50, // VUs created before the test starts
maxVUs: 300, // upper limit if responses slow down
},
},
thresholds: {
http_req_failed: ['rate<0.01'],
http_req_duration: ['p(95)<400'],
dropped_iterations: ['count<100'],
},
};
export default function () {
const res = http.get(`${BASE_URL}/api/products`);
check(res, { 'status 200': (r) => r.status === 200 });
}
Run it:
k6 run -e BASE_URL=https://your_domain rate.js
k6 starts 100 iterations per second, adding VUs up to maxVUs if responses slow down. If it cannot keep up even with the maximum, it skips iterations and counts them in dropped_iterations. A growing dropped_iterations value means the target rate is beyond what the application can serve.
Other useful executors are ramping-arrival-rate (a request rate that changes in stages) and constant-vus (a fixed number of VUs). A single script can define several scenarios that run in parallel, for example browsing users and API clients.
Step 6 - Sending POST requests with JSON
Load tests usually need more than GET requests. This example logs in with a JSON body and then calls an authenticated endpoint with the returned token. Adapt the paths and fields to your API:
nano auth.js
import http from 'k6/http';
import { check, sleep } from 'k6';
const BASE_URL = __ENV.BASE_URL || 'https://your_domain';
export const options = { vus: 10, duration: '2m' };
export default function () {
const login = http.post(
`${BASE_URL}/api/login`,
JSON.stringify({ email: 'loadtest@your_domain', password: __ENV.TEST_PASSWORD }),
{ headers: { 'Content-Type': 'application/json' } },
);
check(login, { 'login 200': (r) => r.status === 200 });
const token = login.json('token');
const me = http.get(`${BASE_URL}/api/me`, {
headers: { Authorization: `Bearer ${token}` },
});
check(me, { 'profile 200': (r) => r.status === 200 });
sleep(1);
}
Pass the password as an environment variable instead of writing it into the script:
k6 run -e BASE_URL=https://your_domain -e TEST_PASSWORD='your_test_password' auth.js
Use a dedicated test account, and remember that login endpoints are often rate limited on purpose.
Step 7 - Exporting results and an HTML report
k6 includes a web dashboard that shows metrics live and can export a self-contained HTML report at the end of the test. Enable it with environment variables:
K6_WEB_DASHBOARD=true K6_WEB_DASHBOARD_EXPORT=report.html \
k6 run -e BASE_URL=https://your_domain load.js
While the test runs, the dashboard is served on http://127.0.0.1:5665. From your workstation you can reach it through an SSH tunnel:
ssh -L 5665:127.0.0.1:5665 your_user@your_server_ip
When the test finishes, report.html is written to the current directory. Copy it to your workstation with scp and open it in a browser.
To keep the end-of-test summary as machine-readable JSON, for example to track results in CI, add --summary-export:
k6 run -e BASE_URL=https://your_domain --summary-export=summary.json load.js
jq '.metrics.http_req_duration' summary.json
{
"avg": 92.41,
"min": 28.77,
"med": 71.30,
"max": 1204.55,
"p(90)": 156.02,
"p(95)": 187.42
}
Troubleshooting
socket: too many open files: each VU holds open connections, and the shell's default limit of 1024 file descriptors is too low for large tests. Raise it for the current shell with ulimit -n 65535 before running k6.
dial: i/o timeout or many failed requests from the start: the target is unreachable from the load generator, or a firewall or rate limiter is blocking it. Test with curl -I https://your_domain from the same machine first.
The k6 machine is at 100% CPU: the load generator is the bottleneck, not the application. Use a larger server, reduce console.log calls in scripts, or split the load across several machines.
Conclusion
You installed k6 from the official repository, wrote tests with checks, ramped virtual users, set thresholds that fail the run when performance degrades, modelled a fixed request rate and exported HTML and JSON reports. As next steps, run a short threshold-based k6 test in your CI pipeline after each deployment, benchmark your database separately with pgbench or mysqlslap, and tune the web server's kernel settings if connection errors appear under load.
