k6 Load Testing — Complete Guide for Node.js APIs 2026

Sanjeev SharmaSanjeev Sharma
7 min read

Advertisement

Introduction

Why This Matters

A feature that works perfectly in development can collapse under production traffic. Load testing before launch is not optional — it is how you find bottlenecks in your database queries, memory leaks under concurrent load, and mis-tuned connection pools. k6 is the leading open-source load testing tool in 2026: scripts are written in JavaScript/TypeScript, it integrates with Grafana Cloud, and it runs efficiently from your CI pipeline or locally without a GUI.

Installing k6

# 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 update && sudo apt install k6
 
# Docker
docker run --rm -i grafana/k6 run - < script.js

Your First Load Test

// tests/load/basic.js
import http from 'k6/http';
import { check, sleep } from 'k6';
 
// Test configuration
export const options = {
  vus: 50,          // 50 concurrent virtual users
  duration: '30s',  // Run for 30 seconds
};
 
export default function () {
  // Each VU runs this function in a loop for the duration
  const response = http.get('http://localhost:3000/api/users');
 
  // Assertions — check() logs pass/fail but does not stop the test
  check(response, {
    'status is 200': (r) => r.status === 200,
    'response time < 500ms': (r) => r.timings.duration < 500,
    'body is valid JSON': (r) => {
      try {
        JSON.parse(r.body as string);
        return true;
      } catch {
        return false;
      }
    },
  });
 
  sleep(1); // Think time between requests
}

Run it:

k6 run tests/load/basic.js

Ramp-Up Scenarios with Stages

Real traffic does not spike instantly. Use stages to simulate gradual ramp-up, sustained load, and ramp-down.

// tests/load/ramp.js
import http from 'k6/http';
import { check, sleep } from 'k6';
 
export const options = {
  stages: [
    { duration: '1m', target: 20 },   // Ramp up to 20 VUs over 1 minute
    { duration: '3m', target: 20 },   // Hold 20 VUs for 3 minutes
    { duration: '1m', target: 100 },  // Spike to 100 VUs
    { duration: '2m', target: 100 },  // Hold the spike
    { duration: '1m', target: 0 },    // Ramp down to 0
  ],
  thresholds: {
    // Test fails if these are not met
    http_req_duration: ['p(95)<500', 'p(99)<1000'],  // 95th percentile < 500ms
    http_req_failed: ['rate<0.01'],                   // Error rate < 1%
    checks: ['rate>0.99'],                            // 99%+ checks pass
  },
};
 
export default function () {
  const res = http.get('http://localhost:3000/api/posts');
 
  check(res, {
    'OK': (r) => r.status === 200,
  });
 
  sleep(Math.random() * 2 + 1); // Random 1-3s think time
}

Testing Authenticated Endpoints

// tests/load/auth.js
import http from 'k6/http';
import { check } from 'k6';
 
const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000';
 
export const options = {
  vus: 20,
  duration: '1m',
  thresholds: {
    http_req_duration: ['p(95)<800'],
    http_req_failed: ['rate<0.05'],
  },
};
 
// setup() runs once before the test — use for auth token retrieval
export function setup() {
  const loginRes = http.post(
    `${BASE_URL}/api/auth/login`,
    JSON.stringify({ email: 'test@example.com', password: 'test-password' }),
    { headers: { 'Content-Type': 'application/json' } }
  );
 
  check(loginRes, { 'login succeeded': (r) => r.status === 200 });
 
  const body = JSON.parse(loginRes.body as string);
  return { token: body.accessToken };
}
 
// data from setup() is passed to the default function
export default function (data: { token: string }) {
  const headers = {
    'Content-Type': 'application/json',
    Authorization: `Bearer ${data.token}`,
  };
 
  // GET protected resource
  const profileRes = http.get(`${BASE_URL}/api/me`, { headers });
  check(profileRes, { 'profile OK': (r) => r.status === 200 });
 
  // POST mutation
  const postRes = http.post(
    `${BASE_URL}/api/posts`,
    JSON.stringify({ title: `Load test post ${Date.now()}`, body: 'Test content' }),
    { headers }
  );
  check(postRes, { 'create post OK': (r) => r.status === 201 });
}

Advanced: Multiple Scenarios

k6 scenarios let you run different user behaviors simultaneously in the same test.

// tests/load/scenarios.js
import http from 'k6/http';
import { check, sleep } from 'k6';
 
export const options = {
  scenarios: {
    // Read-heavy workload: 100 VUs constantly reading
    read_heavy: {
      executor: 'constant-vus',
      vus: 100,
      duration: '5m',
      exec: 'readFlow',
    },
    // Write workload: 10 VUs writing
    write_workload: {
      executor: 'constant-vus',
      vus: 10,
      duration: '5m',
      exec: 'writeFlow',
    },
    // Spike test: ramp to 500 VUs instantly
    spike: {
      executor: 'ramping-vus',
      startVUs: 0,
      stages: [
        { duration: '30s', target: 500 },
        { duration: '1m', target: 500 },
        { duration: '30s', target: 0 },
      ],
      startTime: '4m', // Start spike at minute 4
      exec: 'readFlow',
    },
  },
  thresholds: {
    'http_req_duration{scenario:read_heavy}': ['p(95)<300'],
    'http_req_duration{scenario:write_workload}': ['p(95)<1000'],
    'http_req_failed': ['rate<0.01'],
  },
};
 
export function readFlow() {
  const res = http.get('http://localhost:3000/api/posts?page=1&limit=20');
  check(res, { 'read OK': (r) => r.status === 200 });
  sleep(1);
}
 
export function writeFlow() {
  const res = http.post(
    'http://localhost:3000/api/posts',
    JSON.stringify({ title: 'Test', body: 'Load test' }),
    { headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-token' } }
  );
  check(res, { 'write OK': (r) => r.status === 201 });
  sleep(3);
}

Reading k6 Output

✓ status is 200
✓ response time < 500ms
 
     checks.........................: 99.80%  ✓ 5988  ✗ 12
     data_received..................: 4.1 MB 68 kB/s
     data_sent......................: 890 kB 15 kB/s
     http_req_blocked...............: avg=1.11ms  min=1µs   med=4µs   max=165ms
     http_req_duration..............: avg=214ms   min=52ms  med=198ms max=1.21s
       { expected_response:true }...: avg=214ms   min=52ms  med=198ms max=1.21s
  ✓  http_req_duration.............: p(95)=487ms p(99)=810ms
  ✓  http_req_failed...............: 0.19%  ✓ 5988  ✗ 12
     http_reqs......................: 6000   100/s
     iteration_duration.............: avg=1.21s
     vus............................: 50     min=50  max=50

Key metrics to watch: p(95) and p(99) for latency, http_req_failed for error rate, and http_reqs for throughput.

Integrating k6 into CI/CD

# .github/workflows/load-test.yml
name: Load Test
 
on:
  push:
    branches: [main]
 
jobs:
  load-test:
    runs-on: ubuntu-latest
    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 update && sudo apt install -y k6
 
      - name: Start API (staging)
        run: docker compose up -d && sleep 10
 
      - name: Run load test
        run: k6 run --out json=results.json tests/load/ramp.js
        env:
          BASE_URL: http://localhost:3000
 
      - name: Upload results
        uses: actions/upload-artifact@v4
        with:
          name: k6-results
          path: results.json

Common Mistakes

Mistake 1 — Not using sleep(): without think time, every VU hammers the API as fast as possible, which does not represent real user behavior and produces unrealistic results.

Mistake 2 — Testing in production without rate limits: always load test in a staging environment — an uncapped k6 test can take down a production service.

Mistake 3 — Ignoring the 99th percentile: p(95) looks fine but p(99) at 5 seconds means 1 in 100 users gets a terrible experience. Always check p(99).

Mistake 4 — Not warming up the DB and caches before measuring: the first few seconds of a load test populate caches and warm JIT — ramp-up stages give you more representative steady-state numbers.

Best Practices

  • Define thresholds in every test script — use them as pass/fail gates in your CI pipeline.
  • Use __ENV.BASE_URL to make scripts portable across local, staging, and cloud environments.
  • Store baseline results (JSON output) and compare against them in CI to detect regressions.
  • Monitor your Node.js process during load tests using clinic.js or the built-in --prof flag to correlate CPU and memory with k6 metrics.
  • Use k6 Cloud or Grafana Cloud k6 for distributed load tests that require more than one machine's network bandwidth.

Key Takeaways

  • k6 load test scripts are plain JavaScript — no GUI, no XML config, version-controllable alongside your application code.
  • thresholds turn load test results into CI pass/fail gates based on p(95) latency, error rate, and check pass rate.
  • The stages executor simulates realistic traffic ramp-up, sustained load, and spike scenarios in a single test run.
  • setup() runs once before all VUs start — use it to authenticate and pass tokens to VU functions via its return value.
  • Multiple scenarios let you simulate mixed read/write workloads and spike tests simultaneously in a single k6 run.
  • Always include sleep() in VU functions to simulate realistic user think time between actions.
  • The p(99) latency metric is the most important for user experience — p(95) alone can hide tail latency problems.
  • Never run uncapped load tests against production — always use staging environments with realistic data volumes.

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading