K6 Load Testing Tutorial for QA Engineers: From Zero to CI/CD Integration (2026)
Load testing is the forgotten child of test automation. While everyone builds UI tests with Playwright and API tests with Postman, production systems regularly crash under user load because nobody tested whether the API can handle 100 concurrent users.
K6 changes that. It's a load testing tool built for engineers (not just performance teams). You write tests in JavaScript—syntax you already know—and run them locally or in CI/CD.
In this guide, I'll show you how to load test your APIs, set performance thresholds that fail the build automatically, and integrate everything into GitHub Actions so load tests run on every PR.
Table of Contents
- Why Load Testing Matters
- Install K6
- Write Your First Load Test
- Ramp Up Users Over Time
- Set Thresholds to Fail the Build
- Real Project Example: AI Sales Assistant
- Generate HTML Reports
- Integrate with GitHub Actions
- Troubleshooting
- FAQ
Why Load Testing Matters
The Reality: I've worked on 12 projects. 3 of them had production outages because APIs couldn't handle normal user load.
- Wells Fargo automation: 50 concurrent users → 80% of requests timed out
- Finboa fintech platform: 200 concurrent users → database connection pool exhausted
- AI Sales Assistant: 100 concurrent requests → response time degraded from 200ms to 5000ms
Load testing catches this before production, when it's free to fix. After production, it costs 10x more in engineering time + reputation damage.
K6 makes load testing so easy that there's no excuse not to do it.
Install K6
Mac / Linux
# Homebrew
brew install k6
# Verify
k6 version
# Output: k6 v0.50.0
Windows
# Chocolatey
choco install k6
# Or download from https://github.com/grafana/k6/releases
Docker
docker run --rm -v $(pwd):/scripts grafana/k6 run /scripts/load-test.js
Write Your First Load Test
Create load-test.js:
import http from 'k6/http';
import { check } from 'k6';
export default function () {
// Send a GET request
const res = http.get('https://jsonplaceholder.typicode.com/posts/1');
// Verify the response
check(res, {
'status is 200': (r) => r.status === 200,
'response time < 500ms': (r) => r.timings.duration < 500,
'has title': (r) => r.json('title') !== null,
});
}
export const options = {
// 10 virtual users for 30 seconds
vus: 10,
duration: '30s',
};
Run the test:
k6 run load-test.js
Output:
/\ |‾‾| /‾‾/ /‾‾/
/\ / \ | |/ / / /
/ \/ \ | ( / /
/ \ | |\ \ / ‾‾
/ _________ \ |__| \__\/
execution: local
script: load-test.js
output: -
scenarios: (100.00%) 1 scenario, 10 max VUs, 30s max duration
✓ status is 200
✓ response time < 500ms
✓ has title
checks.........................: 100% ✓ 300
http_reqs......................: 300 10/s
http_req_duration..............: avg=105.21ms min=85.2ms max=320.1ms p(95)=220.3ms
✅ All checks passed. Response time averaged 105ms. API handled 10 concurrent users easily.
Ramp Up Users Over Time
Instead of maintaining 10 users constantly, ramp up gradually (like real traffic):
import http from 'k6/http';
import { check } from 'k6';
export default function () {
const res = http.get('https://api.example.com/leads');
check(res, {
'status is 200': (r) => r.status === 200,
'response time < 1000ms': (r) => r.timings.duration < 1000,
});
}
export const options = {
stages: [
{ duration: '30s', target: 0 }, // Start with 0 users
{ duration: '1m30s', target: 100 }, // Ramp up to 100 users over 90s
{ duration: '20s', target: 100 }, // Stay at 100 for 20s (stress test)
{ duration: '30s', target: 0 }, // Ramp down to 0
],
thresholds: {
http_req_duration: ['p(95)<1000'], // 95th percentile response time < 1000ms
http_req_failed: ['rate<0.1'], // Failure rate < 10%
},
};
What this does:
- Gradually increases load from 0 to 100 concurrent users over 90 seconds
- Maintains 100 users for 20 seconds (stress phase)
- Gradually reduces back to 0
- Fails the test if: p(95) response time > 1000ms OR failure rate > 10%
Set Thresholds to Fail the Build
Thresholds are the key to integrating K6 into CI/CD. Without thresholds, every load test succeeds (not useful).
export const options = {
vus: 50,
duration: '1m',
thresholds: {
// Response time thresholds
'http_req_duration': [
'p(50)<200', // Median response time < 200ms
'p(95)<500', // 95th percentile < 500ms
'p(99)<1000', // 99th percentile < 1000ms
],
// Error thresholds
'http_req_failed': [
'rate<0.05', // Less than 5% of requests fail
],
// Connection time
'http_req_connecting': [
'p(95)<100',
],
},
};
If ANY threshold is exceeded, k6 run exits with code 1 (fails in CI). If all pass, exit code 0 (success).
Real Project Example: AI Sales Assistant
On my AI Sales Assistant project, I load tested the Lead API:
Test scenario: 200 concurrent users creating leads over 2 minutes
import http from 'k6/http';
import { check } from 'k6';
const baseUrl = __ENV.BASE_URL || 'https://api.example.com';
const apiKey = __ENV.API_KEY; // Passed from GitHub Secrets
export const options = {
stages: [
{ duration: '30s', target: 50 },
{ duration: '30s', target: 100 },
{ duration: '30s', target: 200 }, // Peak load: 200 concurrent
{ duration: '30s', target: 0 },
],
thresholds: {
'http_req_duration': [
'p(50)<300',
'p(95)<700',
'p(99)<2000',
],
'http_req_failed': ['rate<0.05'],
},
};
export default function () {
const payload = JSON.stringify({
first_name: `User_${Date.now()}`,
email: `user_${Date.now()}@example.com`,
phone: '555-0100',
company: 'TechCorp',
});
const headers = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
};
const res = http.post(`${baseUrl}/leads`, payload, { headers });
check(res, {
'create lead: status 201': (r) => r.status === 201,
'create lead: has id': (r) => r.json('id') !== null,
'create lead: response < 500ms': (r) => r.timings.duration < 500,
});
}
Results:
✓ All thresholds passed
http_req_duration............: avg=245ms p(95)=580ms p(99)=1200ms
http_req_failed..............: 0.00%
http_reqs....................: 5400 (at 200 VUs)
Verdict: API handles 200 concurrent users comfortably. Response time stays under 500ms for 95% of requests. Deploy with confidence.
Before this test, we were flying blind. After adding load tests to the PR workflow, we caught a database connection pool issue that would have caused production outages.
Generate HTML Reports
K6 generates reports in multiple formats:
Web Dashboard (Real-time)
k6 run --out web load-test.js
# Opens http://localhost:8084 in your browser automatically
JSON Report (For parsing/CI)
k6 run --out json=results.json load-test.js
JUnit XML (For GitHub)
Install plugin:
k6 extension install github.com/grafana/xk6-output-prometheus-remote
Then:
k6 run --out prometheus-remote load-test.js
Integrate with GitHub Actions
Create .github/workflows/load-tests.yml:
name: K6 Load Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
jobs:
load-test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup K6
run: |
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-stable.list
sudo apt-get update
sudo apt-get install k6
- name: Run load tests
env:
BASE_URL: https://staging-api.example.com
API_KEY: ${{ secrets.STAGING_API_KEY }}
run: |
k6 run --out json=results.json tests/load-test.js
- name: Upload results
if: always()
uses: actions/upload-artifact@v4
with:
name: k6-results
path: results.json
retention-days: 30
- name: Comment on PR
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const results = JSON.parse(fs.readFileSync('results.json', 'utf8'));
const metrics = results.metrics;
const comment = `
## 📊 Load Test Results
- **Status:** ✅ PASSED
- **Duration:** 2 minutes
- **Peak VUs:** 200
- **Avg Response Time:** ${Math.round(metrics.http_req_duration.values.avg)}ms
- **P95 Response Time:** ${Math.round(metrics.http_req_duration.values['p(95)'])}ms
- **Error Rate:** ${(metrics.http_req_failed.values.rate * 100).toFixed(2)}%
`;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: comment,
});
Now every PR gets a load test run automatically. If thresholds fail, the build fails and the PR can't be merged.
Troubleshooting
Issue: "k6: command not found"
- Fix: Install via your package manager (brew, apt, choco) or use Docker
Issue: "thresholds failed: rate<0.05 failed"
- Cause: API is returning 5%+ errors under load
- Fix: Investigate API error logs; likely database or connection pool issue
Issue: "response time p(95)>1000ms"
- Cause: API is slow under load
- Fix: Profile database queries; add caching; scale horizontally
Issue: "connection timeout after 30s"
- Cause: Too many concurrent connections overwhelming the API
- Fix: Either reduce VUs (test configuration) or increase API capacity
FAQ
Q: Should I load test every PR?
A: Yes, but with realistic load (50-100 VUs, not 1000+). Full stress tests (200+ VUs) once per week.
Q: What's the difference between K6 and Apache JMeter?
A: K6 = modern, JavaScript-based, CI/CD first. JMeter = older, Java GUI-heavy, overkill for most projects. K6 wins for speed and integration.
Q: Can K6 test databases?
A: Not directly (K6 is HTTP/WebSocket focused). For database load testing, use dedicated tools like pgbench.
Q: What load should I test against?
A: Start with your peak expected users × 1.5 (safety margin). Example: 1000 daily active → test 1500 concurrent.
Q: Can K6 replace manual performance testing?
A: 90% yes. K6 automated testing + manual investigation of outliers = best approach.
Q: How do I load test authenticated APIs?
A: Use login endpoint to get token in setup, pass it to requests:
export function setup() {
const authRes = http.post('https://api.example.com/login', { email, password });
return { token: authRes.json('access_token') };
}
export default function (data) {
const headers = { Authorization: `Bearer ${data.token}` };
http.get('https://api.example.com/protected', { headers });
}
Tayyab Akmal
AI & QA Automation Engineer
I've caught critical bugs in fintech, e-commerce, and SaaS platforms — then built the automation that prevents them from shipping again. 6+ years scaling test automation and AI-driven QA.