TechLead
Lesson 18 of 22
5 min read
Performance Engineering

Load Testing with k6

Write k6 load test scripts, simulate realistic traffic patterns, and identify performance bottlenecks under load

Why Load Testing?

Performance testing in development only tells half the story. An application that responds in 50ms under a single user may take 5 seconds under 1000 concurrent users. Load testing simulates realistic traffic patterns to identify bottlenecks, determine capacity limits, and verify that performance meets requirements under expected (and peak) load.

Types of Load Tests

  • Smoke test: Minimal load (1-5 users) to verify the system works under basic conditions
  • Load test: Expected normal load to verify performance meets SLAs
  • Stress test: Beyond expected load to find the breaking point
  • Spike test: Sudden burst of traffic to test auto-scaling and recovery
  • Soak test: Sustained load over hours to find memory leaks and resource exhaustion

k6 Fundamentals

// k6 is written in JavaScript (ES6)
// Install: brew install k6 OR npm install -g k6

// basic-load-test.js
import http from 'k6/http';
import { check, sleep, group } from 'k6';
import { Rate, Trend, Counter } from 'k6/metrics';

// Custom metrics
const errorRate = new Rate('error_rate');
const apiDuration = new Trend('api_duration');
const requestCount = new Counter('total_requests');

// Test configuration
export const options = {
  stages: [
    { duration: '1m', target: 50 },   // Ramp up to 50 users
    { duration: '3m', target: 50 },   // Stay at 50 users
    { duration: '2m', target: 200 },  // Ramp up to 200 users
    { duration: '3m', target: 200 },  // Stay at 200 users
    { duration: '1m', target: 0 },    // Ramp down to 0
  ],
  thresholds: {
    http_req_duration: ['p(95)<500', 'p(99)<1000'], // 95th percentile < 500ms
    error_rate: ['rate<0.05'],                       // Error rate < 5%
    http_req_failed: ['rate<0.01'],                  // HTTP failures < 1%
  },
};

export default function () {
  group('Homepage', () => {
    const res = http.get('https://example.com/', {
      headers: { 'Accept-Encoding': 'gzip, br' },
    });

    check(res, {
      'status is 200': (r) => r.status === 200,
      'response time < 500ms': (r) => r.timings.duration < 500,
      'has content-type': (r) => r.headers['Content-Type'] !== '',
    });

    errorRate.add(res.status !== 200);
    apiDuration.add(res.timings.duration);
    requestCount.add(1);
  });

  group('API - Products', () => {
    const res = http.get('https://example.com/api/products?limit=20', {
      headers: {
        'Accept': 'application/json',
        'Accept-Encoding': 'gzip, br',
      },
    });

    check(res, {
      'status is 200': (r) => r.status === 200,
      'response is JSON': (r) => {
        try { JSON.parse(r.body); return true; } catch { return false; }
      },
      'has products': (r) => JSON.parse(r.body).length > 0,
      'response time < 200ms': (r) => r.timings.duration < 200,
    });

    errorRate.add(res.status !== 200);
    apiDuration.add(res.timings.duration);
  });

  sleep(Math.random() * 3 + 1); // Think time: 1-4 seconds
}

Advanced k6 Scenarios

// realistic-user-flow.js — Simulate actual user journeys
import http from 'k6/http';
import { check, sleep, group } from 'k6';

export const options = {
  scenarios: {
    // Browsing users (high volume, light load)
    browsers: {
      executor: 'ramping-vus',
      startVUs: 0,
      stages: [
        { duration: '2m', target: 100 },
        { duration: '5m', target: 100 },
        { duration: '1m', target: 0 },
      ],
      exec: 'browsingFlow',
    },
    // Purchasing users (lower volume, heavier operations)
    buyers: {
      executor: 'constant-arrival-rate',
      rate: 10,            // 10 iterations per second
      timeUnit: '1s',
      duration: '5m',
      preAllocatedVUs: 50,
      exec: 'purchaseFlow',
    },
    // API consumers (steady API traffic)
    apiClients: {
      executor: 'constant-vus',
      vus: 20,
      duration: '5m',
      exec: 'apiFlow',
    },
  },
  thresholds: {
    'http_req_duration{scenario:browsers}': ['p(95)<1000'],
    'http_req_duration{scenario:buyers}': ['p(95)<2000'],
    'http_req_duration{scenario:apiClients}': ['p(95)<300'],
  },
};

export function browsingFlow() {
  group('Browse Products', () => {
    http.get('https://example.com/');
    sleep(2);
    http.get('https://example.com/products');
    sleep(3);
    http.get('https://example.com/products/random-product');
    sleep(2);
  });
}

export function purchaseFlow() {
  group('Purchase Flow', () => {
    // Browse
    http.get('https://example.com/products');
    sleep(1);

    // Add to cart
    const addRes = http.post('https://example.com/api/cart', JSON.stringify({
      productId: 'prod_123',
      quantity: 1,
    }), { headers: { 'Content-Type': 'application/json' } });

    check(addRes, { 'added to cart': (r) => r.status === 200 });
    sleep(1);

    // Checkout
    const checkoutRes = http.post('https://example.com/api/checkout', JSON.stringify({
      cartId: JSON.parse(addRes.body).cartId,
    }), { headers: { 'Content-Type': 'application/json' } });

    check(checkoutRes, { 'checkout success': (r) => r.status === 200 });
  });
}

export function apiFlow() {
  const res = http.get('https://example.com/api/products?limit=20');
  check(res, {
    'api 200': (r) => r.status === 200,
    'api fast': (r) => r.timings.duration < 300,
  });
  sleep(0.5);
}

Spike Testing

// spike-test.js — Simulate sudden traffic bursts
import http from 'k6/http';
import { check, sleep } from 'k6';

export const options = {
  stages: [
    { duration: '30s', target: 10 },   // Normal load
    { duration: '10s', target: 500 },  // Spike!
    { duration: '1m', target: 500 },   // Sustain spike
    { duration: '10s', target: 10 },   // Back to normal
    { duration: '1m', target: 10 },    // Recovery period
  ],
  thresholds: {
    http_req_duration: ['p(95)<2000'],  // Relaxed during spike
    http_req_failed: ['rate<0.10'],     // Allow 10% errors during spike
  },
};

export default function () {
  const res = http.get('https://example.com/');
  check(res, {
    'status 200': (r) => r.status === 200,
    'no server errors': (r) => r.status < 500,
  });
  sleep(1);
}

// Run: k6 run spike-test.js
// Output results to JSON: k6 run --out json=results.json spike-test.js
// Output to InfluxDB: k6 run --out influxdb=http://localhost:8086/k6 spike-test.js

Load Testing Best Practices

  • Test realistic scenarios: Simulate actual user journeys, not just single endpoints
  • Include think time: Add sleep() between requests to simulate real user behavior
  • Set thresholds: Define pass/fail criteria based on your SLAs
  • Ramp gradually: Increase load progressively to identify the inflection point
  • Test in staging: Never load test production without careful planning
  • Run in CI: Automate load tests as part of your deployment pipeline

Continue Learning