Part of our Performance & Scalability series
Read the complete guidek6 Load Testing: Stress-Test Your APIs Before Launch
Shipping a product without load testing is a gamble. You do not know your breaking point until users find it for you — usually during a product launch, a viral moment, or a sales spike. k6 is the modern load testing tool that lets you write tests in JavaScript, run them from CI, and discover your performance ceiling before users do. It is developer-friendly, resource-efficient (k6 uses goroutines, not threads), and integrates cleanly with Grafana and Prometheus for real-time metrics.
This guide covers k6 from first script through complex multi-scenario load tests, custom metrics, thresholds, CI integration, and production-safe testing patterns for Node.js/NestJS APIs.
Key Takeaways
- k6 scripts are JavaScript but run in a Go runtime — no Node.js APIs (no
require, nofs, nosetTimeout)- Virtual Users (VUs) are concurrent simulated users; iterations are individual script executions
- Always set thresholds before running — failing thresholds stop the test and fail CI
- Use scenarios (constant-vus, ramping-vus, constant-arrival-rate) to model real traffic patterns
- Never load test production without coordinating with ops — test against staging or a production clone
- The
http_req_durationmetric is your primary SLA metric — p95 and p99 matter more than averages- Rate limit your k6 runners — load test traffic should not exhaust your rate limit budget
- Use k6 Cloud or Grafana k6 for distributed load testing across geographic regions
Installation and First Script
# macOS
brew install k6
# Ubuntu/Debian
sudo gpg -k && sudo gpg --no-default-keyring --keyring /usr/share/keyrings/k6-archive-keyring.gpg --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69
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-get update && sudo apt-get install k6
# Windows (via Chocolatey)
choco install k6
Your first k6 script:
// k6/homepage-load.js
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate } from 'k6/metrics';
// Custom metric: error rate
const errorRate = new Rate('error_rate');
export const options = {
// Ramp up to 50 VUs over 1 minute, hold 2 minutes, ramp down
stages: [
{ duration: '1m', target: 50 },
{ duration: '2m', target: 50 },
{ duration: '1m', target: 0 },
],
thresholds: {
http_req_duration: ['p(95)<500', 'p(99)<1000'], // 95th pct < 500ms
http_req_failed: ['rate<0.01'], // Less than 1% errors
error_rate: ['rate<0.01'],
},
};
const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000';
export default function () {
const res = http.get(`${BASE_URL}/`);
const ok = check(res, {
'status is 200': (r) => r.status === 200,
'response time < 500ms': (r) => r.timings.duration < 500,
'body contains ECOSIRE': (r) => r.body.includes('ECOSIRE'),
});
errorRate.add(!ok);
sleep(1); // 1 second think time between iterations
}
Run it:
k6 run k6/homepage-load.js
k6 run --env BASE_URL=https://staging.ecosire.com k6/homepage-load.js
k6 run --vus 100 --duration 30s k6/homepage-load.js # Quick smoke test
API Load Test with Authentication
// k6/api-load.js
import http from 'k6/http';
import { check, group, sleep } from 'k6';
import { Counter, Trend } from 'k6/metrics';
const apiCalls = new Counter('api_calls');
const authDuration = new Trend('auth_duration');
export const options = {
scenarios: {
// Constant arrival rate: 100 requests/second regardless of VU count
constant_load: {
executor: 'constant-arrival-rate',
rate: 100,
timeUnit: '1s',
duration: '3m',
preAllocatedVUs: 50,
maxVUs: 200,
},
},
thresholds: {
http_req_duration: ['p(95)<300', 'p(99)<500'],
'http_req_duration{type:api}': ['p(95)<200'],
http_req_failed: ['rate<0.005'], // 0.5% error budget
},
};
const BASE_URL = __ENV.BASE_URL || 'http://localhost:3001';
// setup() runs once before VUs start
export function setup() {
// Authenticate and return tokens for VUs to use
const loginRes = http.post(`${BASE_URL}/auth/login`, JSON.stringify({
email: __ENV.TEST_EMAIL,
password: __ENV.TEST_PASSWORD,
}), {
headers: { 'Content-Type': 'application/json' },
});
check(loginRes, { 'login successful': (r) => r.status === 200 });
const { accessToken } = loginRes.json();
return { accessToken };
}
export default function (data) {
const { accessToken } = data;
const headers = {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
};
group('Contacts API', () => {
// List contacts
const listRes = http.get(`${BASE_URL}/contacts?page=1&limit=20`, {
headers,
tags: { type: 'api', endpoint: 'contacts-list' },
});
check(listRes, {
'list contacts: 200': (r) => r.status === 200,
'list contacts: has items': (r) => r.json('data') !== null,
});
apiCalls.add(1);
sleep(0.5);
// Create a contact
const createRes = http.post(`${BASE_URL}/contacts`, JSON.stringify({
name: `Load Test User ${Date.now()}`,
email: `load-test-${Date.now()}@example.com`,
}), {
headers,
tags: { type: 'api', endpoint: 'contacts-create' },
});
check(createRes, {
'create contact: 201': (r) => r.status === 201,
});
apiCalls.add(1);
});
sleep(1);
}
// teardown() runs once after all VUs finish
export function teardown(data) {
// Clean up test data if needed
console.log(`Test complete. Data: ${JSON.stringify(data)}`);
}
Ramping VU Scenarios
Model real traffic patterns with multiple stages and scenarios:
// k6/stress-test.js
export const options = {
scenarios: {
// Scenario 1: Normal traffic baseline
normal_traffic: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '2m', target: 20 },
{ duration: '5m', target: 20 },
],
gracefulRampDown: '30s',
},
// Scenario 2: Sudden spike (marketing campaign goes live)
traffic_spike: {
executor: 'ramping-vus',
startVUs: 0,
startTime: '7m', // Starts after baseline is stable
stages: [
{ duration: '30s', target: 200 }, // Rapid spike
{ duration: '2m', target: 200 }, // Hold spike
{ duration: '30s', target: 20 }, // Fall back
],
},
// Scenario 3: Soak test for memory leaks
soak_test: {
executor: 'constant-vus',
vus: 10,
duration: '30m', // Run for 30 minutes
startTime: '10m',
},
},
thresholds: {
'http_req_duration{scenario:normal_traffic}': ['p(95)<300'],
'http_req_duration{scenario:traffic_spike}': ['p(95)<800'],
http_req_failed: ['rate<0.02'],
},
};
Blog/Sitemap Crawl Test
// k6/sitemap-crawl.js
import http from 'k6/http';
import { check, sleep } from 'k6';
import { SharedArray } from 'k6/data';
// Load URLs from sitemap (pre-fetched and saved as JSON)
const urls = new SharedArray('sitemap urls', function () {
// In real usage, fetch sitemap XML and parse URLs beforehand
// SharedArray is loaded once and shared across all VUs (memory efficient)
return JSON.parse(open('./sitemap-urls.json'));
});
export const options = {
vus: 20,
duration: '5m',
thresholds: {
http_req_duration: ['p(95)<2000'], // Public pages can be slower
http_req_failed: ['rate<0.01'],
},
};
export default function () {
// Each VU picks a random URL from the sitemap
const url = urls[Math.floor(Math.random() * urls.length)];
const res = http.get(url, {
headers: {
// Identify as load tester in logs
'User-Agent': 'k6-load-tester/1.0',
},
timeout: '10s',
});
check(res, {
'status is 200': (r) => r.status === 200,
'no error page': (r) => !r.body.includes('Error'),
'has canonical tag': (r) => r.body.includes('rel="canonical"'),
'response time < 2000ms': (r) => r.timings.duration < 2000,
});
sleep(Math.random() * 2 + 1); // Random 1-3 second think time
}
Custom Metrics and Thresholds
// k6/checkout-load.js
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Counter, Rate, Trend, Gauge } from 'k6/metrics';
// Custom metrics for business-specific tracking
const checkoutSuccesses = new Counter('checkout_successes');
const checkoutFailures = new Counter('checkout_failures');
const checkoutDuration = new Trend('checkout_duration');
const activeCheckouts = new Gauge('active_checkouts');
export const options = {
stages: [
{ duration: '1m', target: 10 },
{ duration: '3m', target: 30 },
{ duration: '1m', target: 0 },
],
thresholds: {
checkout_duration: ['p(95)<3000'], // Checkout < 3s at p95
checkout_failures: ['count<10'], // Max 10 absolute failures
http_req_duration: ['p(99)<2000'],
},
};
export default function (data) {
activeCheckouts.add(1);
const start = Date.now();
// Step 1: Add to cart
const cartRes = http.post('/api/cart/add', JSON.stringify({
productId: 'prod_odoo_customization',
quantity: 1,
}), { headers: { 'Content-Type': 'application/json' } });
if (!check(cartRes, { 'add to cart: 200': (r) => r.status === 200 })) {
checkoutFailures.add(1);
activeCheckouts.add(-1);
return;
}
sleep(2);
// Step 2: Create checkout session
const checkoutRes = http.post('/api/billing/checkout', JSON.stringify({
cartId: cartRes.json('cartId'),
}), { headers: { 'Content-Type': 'application/json' } });
const success = check(checkoutRes, {
'checkout session created': (r) => r.status === 200,
'has session URL': (r) => r.json('url') !== null,
});
if (success) {
checkoutSuccesses.add(1);
checkoutDuration.add(Date.now() - start);
} else {
checkoutFailures.add(1);
}
activeCheckouts.add(-1);
sleep(1);
}
Grafana Output Integration
Stream k6 metrics to InfluxDB and visualize in Grafana during the test:
# Run with InfluxDB output
k6 run --out influxdb=http://localhost:8086/k6 k6/api-load.js
# Or use the k6 Grafana integration (k6 v0.45+)
k6 run --out experimental-prometheus-rw k6/api-load.js
# docker-compose.monitoring.yml
services:
influxdb:
image: influxdb:2.7
ports: ["8086:8086"]
environment:
INFLUXDB_DB: k6
INFLUXDB_ADMIN_USER: admin
INFLUXDB_ADMIN_PASSWORD: password
grafana:
image: grafana/grafana:latest
ports: ["3030:3000"]
environment:
GF_AUTH_ANONYMOUS_ENABLED: "true"
volumes:
- ./grafana/dashboards:/etc/grafana/provisioning/dashboards
- ./grafana/datasources:/etc/grafana/provisioning/datasources
CI Integration
# .github/workflows/ci.yml (load test section)
load-tests:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' # Only on main branch
needs: [deploy-staging]
steps:
- uses: actions/checkout@v4
- name: Install k6
run: |
sudo gpg --no-default-keyring --keyring /usr/share/keyrings/k6-archive-keyring.gpg \
--keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69
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-get update && sudo apt-get install k6
- name: Run smoke test (30 VUs, 60s)
run: |
k6 run --vus 30 --duration 60s \
--env BASE_URL=${{ secrets.STAGING_URL }} \
--env TEST_EMAIL=${{ secrets.TEST_EMAIL }} \
--env TEST_PASSWORD=${{ secrets.TEST_PASSWORD }} \
apps/api/k6/api-load.js
- name: Upload k6 results
uses: actions/upload-artifact@v4
if: always()
with:
name: k6-results
path: k6-results/
Performance Baselines
Establish baselines and alert on regressions:
| Endpoint | p50 | p95 | p99 | Error Budget |
|---|---|---|---|---|
GET / (homepage) | 120ms | 400ms | 800ms | 0.1% |
GET /api/contacts | 50ms | 150ms | 300ms | 0.5% |
POST /api/contacts | 80ms | 200ms | 400ms | 0.5% |
POST /auth/login | 200ms | 500ms | 1000ms | 1% |
POST /billing/checkout | 500ms | 1500ms | 3000ms | 1% |
GET /blog/[slug] | 100ms | 300ms | 600ms | 0.1% |
Frequently Asked Questions
What is the difference between VUs and requests per second?
VUs (Virtual Users) are concurrent simulated users. Each VU runs your default function in a loop. Requests per second depends on how fast each iteration runs. If each VU iteration takes 2 seconds (including sleep time) and you have 100 VUs, you get roughly 50 RPS. Use constant-arrival-rate executor when you need to target a specific RPS regardless of iteration duration.
Should I load test against production or staging?
Always prefer staging. Production load tests risk impacting real users, exhausting rate limits, creating real orders from test data, and triggering real webhook events. If you must test production, do it during low-traffic windows, use test payment tokens, and have a rollback plan. k6 Cloud supports geo-distributed load generation so you can test your CDN edge performance without touching the origin.
How do I handle auth token expiry during long tests?
For short tests (under 15 minutes), get a token in setup() and pass it to all VUs via data. For long tests (over 15 minutes), implement token refresh in the test script: check token age in the default function and refresh when it approaches expiry. Store the token in a JavaScript variable local to each VU.
What is the difference between stages and scenarios?
stages is a shorthand for a single ramping-vus scenario — good for simple ramp-up/hold/ramp-down patterns. scenarios gives you full control: multiple simultaneous traffic patterns, different executors per scenario, per-scenario thresholds, and scenario tagging in metrics. Use scenarios for realistic multi-pattern testing (baseline + spike + soak simultaneously).
How many VUs can a single k6 process handle?
A single k6 process on modern hardware can sustain 5,000-10,000 VUs and generate 50,000-100,000 RPS, depending on script complexity and response size. For higher loads, use k6 cloud or run multiple k6 instances behind a load distributor. Each VU is extremely lightweight (a goroutine) — k6 uses far fewer resources than JMeter or Gatling for equivalent VU counts.
Next Steps
Load testing reveals your application's true performance profile before users discover it under stress. The patterns in this guide — ramping scenarios, custom thresholds, CI integration, and Grafana dashboards — give you the infrastructure to find and fix performance bottlenecks continuously.
ECOSIRE builds performance-validated NestJS APIs with k6 load tests covering homepage, API endpoints, checkout flows, and full sitemap crawls. Explore our backend engineering services to learn how we build for performance from day one.
Written by
ECOSIRE Research and Development Team
Building enterprise-grade digital products at ECOSIRE. Sharing insights on Odoo integrations, e-commerce automation, and AI-powered business solutions.
Related Articles
API Rate Limiting: Patterns and Best Practices
Master API rate limiting with token bucket, sliding window, and fixed counter patterns. Protect your backend with NestJS throttler, Redis, and real-world configuration examples.
GoHighLevel API and Webhooks: Custom Integrations
Complete developer guide to GoHighLevel API and webhooks. Build custom integrations, sync data with external systems, and extend GHL capabilities with REST API and webhook automation.
NestJS 11 Enterprise API Patterns
Master NestJS 11 enterprise patterns: guards, interceptors, pipes, multi-tenancy, and production-ready API design for scalable backend systems.
More from Performance & Scalability
Nginx Production Configuration: SSL, Caching, and Security
Nginx production configuration guide: SSL termination, HTTP/2, caching headers, security headers, rate limiting, reverse proxy setup, and Cloudflare integration patterns.
Odoo Performance Tuning: PostgreSQL and Server Optimization
Expert guide to Odoo 19 performance tuning. Covers PostgreSQL configuration, indexing, query optimization, Nginx caching, and server sizing for enterprise deployments.
Odoo vs Acumatica: Cloud ERP for Growing Businesses
Odoo vs Acumatica compared for 2026: unique pricing models, scalability, manufacturing depth, and which cloud ERP fits your growth trajectory.
Testing and Monitoring AI Agents in Production
A complete guide to testing and monitoring AI agents in production environments. Covers evaluation frameworks, observability, drift detection, and incident response for OpenClaw deployments.
Compliance Monitoring Agents with OpenClaw
Deploy OpenClaw AI agents for continuous compliance monitoring. Automate regulatory checks, policy enforcement, audit trail generation, and compliance reporting.
Optimizing AI Agent Costs: Token Usage and Caching
Practical strategies for reducing AI agent operational costs through token optimization, caching, model routing, and usage monitoring. Real savings from production OpenClaw deployments.