Load Testing with k6: Scripts, Virtual Users, Thresholds, and Browser Testing
k6 is a modern, developer-friendly load testing tool that uses JavaScript to define test scenarios — making performance testing accessible to developers who already know JavaScript.
What You’ll Learn
- Writing k6 test scripts with JavaScript
- Configuring virtual users, ramp-up stages, and test duration
- Setting performance thresholds that act as quality gates
- Browser-level testing with k6 browser
- Integrating k6 into CI/CD pipelines
Why k6 Matters
Traditional load testing tools like JMeter use XML-based test plans that are hard to version control and review. k6 uses JavaScript — a language developers already know — with a clean API for defining load patterns, checks, and thresholds. It’s designed for CI/CD integration from the ground up, supports cloud execution, and includes browser-level testing for measuring frontend performance.
Doda Browser uses k6 to load test its sync service — simulating thousands of concurrent users syncing bookmarks, passwords, and settings across devices.
Learning Path
flowchart LR
A[Performance Testing] --> B[k6 Load Testing<br/>You are here]
B --> C[CI/CD Integration]
C --> D[Production Monitoring]
D --> E[Capacity Planning]
style B fill:#f90,color:#fff
Setup
# Install k6 (macOS)
brew install k6
# Linux (Debian/Ubuntu)
sudo apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69
echo "deb https://dl.k6.io/deb stable main" | sudo tee /etc/apt/sources.list.d/k6.list
sudo apt-get update && sudo apt-get install k6
# Windows
winget install k6Your First Test
// first-test.js
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
vus: 10, // 10 virtual users
duration: '30s', // Run for 30 seconds
};
export default function () {
const res = http.get('https://test.k6.io');
check(res, {
'status is 200': (r) => r.status === 200,
'response time < 500ms': (r) => r.timings.duration < 500,
});
sleep(1);
}Run it:
k6 run first-test.jsExpected output:
/\ |‾‾| /‾‾/ /‾‾/
/\ / \ | |/ / / /
/ \/ \ | ( / ‾‾\
/ \ | |\ \ | (‾) |
/ __________ \ |__| \__\ \_____/ .io
execution: local
script: first-test.js
output: -
scenarios: (100.00%) 1 scenario, 10 max vus, 1m0s max duration (incl. graceful stop):
* default: 10 looping users for 30s (startStages: 0s, gracefulStop: 30s)
✓ status is 200
✓ response time < 500ms
checks.........................: 100.00% ✓ 280 ✗ 0
data_received..................: 2.1 MB 69 kB/s
http_req_blocked...............: avg=1.2ms p(95)=4.1ms
http_req_connecting............: avg=0.8ms p(95)=3.2ms
http_req_duration..............: avg=98.3ms p(95)=187.2ms
http_req_receiving.............: avg=0.3ms p(95)=0.6ms
http_req_sending...............: avg=0.1ms p(95)=0.2ms
http_req_tls_handshaking......: avg=0.8ms p(95)=3.5ms
http_req_waiting...............: avg=97.1ms p(95)=184.3ms
http_req_failed................: 0.00% ✓ 0 ✗ 280
vus............................: 10 min=10 max=10
vus_max........................: 10 min=10 max=10Virtual Users and Stages
Control load patterns with stages:
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
stages: [
{ duration: '2m', target: 50 }, // Ramp up to 50 users
{ duration: '5m', target: 50 }, // Stay at 50 users
{ duration: '2m', target: 100 }, // Ramp up to 100
{ duration: '5m', target: 100 }, // Stay at 100
{ duration: '2m', target: 0 }, // Ramp down
],
};Use Cases
| Pattern | Purpose |
|---|---|
| Ramp-up only | Find the breaking point |
| Steady state | Verify sustained load handling |
| Spike | Sudden traffic surge |
| Ramp-down | Verify graceful shutdown |
Checks and Thresholds
Checks
Checks verify conditions during the test but don’t stop it:
import http from 'k6/http';
import { check } from 'k6';
export default function () {
const res = http.get('https://api.example.com/users');
check(res, {
'status is 200': (r) => r.status === 200,
'has users array': (r) => Array.isArray(r.json()),
'response time < 300ms': (r) => r.timings.duration < 300,
});
}Thresholds
Thresholds define pass/fail criteria for the entire test:
export const options = {
thresholds: {
http_req_duration: [
'p(95)<500', // 95% of requests under 500ms
'p(99)<1500', // 99% under 1.5s
],
http_req_failed: ['rate<0.01'], // Less than 1% failures
checks: ['rate>0.95'], // At least 95% checks pass
},
};Thresholds can fail the build in CI:
k6 run script.js
# If thresholds are exceeded, k6 exits with non-zero code
# This can fail a CI pipelineCustom Metrics
Define custom metrics for specific business requirements:
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate, Trend, Counter } from 'k6/metrics';
const errorRate = new Rate('errors');
const loginDuration = new Trend('login_duration');
const ordersCreated = new Counter('orders_created');
export default function () {
// Login
const loginRes = http.post('https://api.example.com/login', {
username: 'test_user',
password: 'test_pass',
});
loginDuration.add(loginRes.timings.duration);
if (loginRes.status === 200) {
const token = loginRes.json().token;
// Create order
const orderRes = http.post(
'https://api.example.com/orders',
JSON.stringify({ product: 'book', quantity: 1 }),
{ headers: { Authorization: `Bearer ${token}` } }
);
if (orderRes.status === 201) {
ordersCreated.add(1);
}
errorRate.add(orderRes.status !== 201);
} else {
errorRate.add(1);
}
sleep(1);
}Browser Testing
k6 can measure browser-level performance using the browser module:
import { browser } from 'k6/experimental/browser';
import { check } from 'k6';
export const options = {
scenarios: {
ui: {
executor: 'shared-iterations',
options: {
browser: {
type: 'chromium',
},
},
},
},
thresholds: {
browser_web_vital_inp: ['p(75)<200'], // Interaction to Next Paint
browser_web_vital_lcp: ['p(75)<2500'], // Largest Contentful Paint
},
};
export default async function () {
const page = browser.newPage();
try {
await page.goto('https://example.com');
// Measure real user metrics
check(page, {
'LCP is acceptable': () =>
page.metrics().largestContentfulPaint < 2500,
});
// Interact with the page
await page.fill('#search', 'performance testing');
await page.click('button[type="submit"]');
await page.waitForSelector('.results');
check(page, {
'results are visible': () =>
page.locator('.results').isVisible(),
});
} finally {
page.close();
}
}CI/CD Integration
# .github/workflows/load-test.yml
name: Load Test
on:
schedule:
- cron: '0 6 * * 1' # Every Monday at 6 AM
workflow_dispatch: # Manual trigger
jobs:
load-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run k6 load test
uses: grafana/k6-action@v0.2.0
with:
filename: tests/load/load-test.js
flags: --out json=results.json
- name: Upload results
uses: actions/upload-artifact@v4
with:
name: k6-results
path: results.json
- name: Check thresholds
run: |
if jq -e '.metrics.http_req_duration.values["p(95)"] > 500' results.json; then
echo "Performance threshold exceeded!"
exit 1
fiCommon k6 Mistakes
1. Not Warming Up
Starting at full load immediately can skew results — caches are empty, connections aren’t pooled, JIT hasn’t warmed up.
Fix: Include a ramp-up stage before measurement stages.
2. Testing Only One Endpoint
Testing only the health-check endpoint doesn’t represent real user behavior.
Fix: Simulate realistic user journeys — login, browse, search, interact.
3. No sleep() Between Requests
Without sleep(), virtual users send requests as fast as possible, which may overwhelm the system unrealistically.
Fix: Add realistic think time with sleep() between user actions.
4. Not Validating Responses
Checking only status codes misses response errors, wrong data, or slow responses.
Fix: Use check() to validate response bodies and timing.
5. Ignoring Thresholds in CI
Running load tests without thresholds means they never fail the build.
Fix: Set meaningful thresholds that reflect your performance requirements.
6. Testing Against Staging That Differs From Production
If staging has half the CPU or a different database, results won’t predict production behavior.
Fix: Match staging to production as closely as possible.
7. Not Measuring Both Sides
Server-side response time is only half the story. Browser-level testing measures what the user actually experiences.
Fix: Combine API-level load testing with browser-level user experience measurement.
Practice Questions
1. What is a virtual user in k6?
A simulated user that executes the default function in a loop, making HTTP requests and performing checks.
2. What is the difference between checks and thresholds?
Checks verify conditions during the test. Thresholds define pass/fail criteria for the entire test run. Thresholds can fail the build.
3. Why should you include ramp-up stages in load tests?
To warm up the system (caches, connections, JIT) and find the load level where performance degrades.
4. What are Web Vitals in browser testing?
Metrics that measure real user experience: LCP (loading), INP (interactivity), CLS (visual stability).
5. How do you integrate k6 into a CI/CD pipeline?
Use the k6-action GitHub Action or run k6 run directly, with thresholds that exit non-zero on failure.
Challenge: Write a k6 script that simulates a realistic e-commerce user journey: search for a product, view product details, add to cart, and checkout. Include thresholds for response times and error rates.
FAQ
What’s Next
| Tutorial | What You’ll Learn |
|---|---|
| Performance Testing Strategies | Comprehensive performance testing guide |
| k6 Cloud and Distributed Testing | Running large-scale tests with k6 Cloud |
| JMeter Performance Testing | Enterprise performance testing with JMeter |
Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro. Updated 2026-06-20.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro