Back to Articles
AdvancedAdvanced

Modern Load Testing with k6

Performance testing with k6, a modern load testing tool

14 min read
...
k6load-testingperformancetesting
Banner for Modern Load Testing with k6

Introduction

k6 is a modern, developer-friendly load testing tool built for the DevOps era. Unlike older tools like JMeter, k6 lets you write tests in JavaScript, integrates seamlessly with CI/CD, and provides powerful analytics. This guide will teach you how to use k6 to ensure your applications perform under pressure.

Why k6?

Traditional Load Testing Problems:

  • Complex GUI tools (JMeter, LoadRunner)
  • Hard to version control
  • Difficult to integrate with CI/CD
  • Resource-intensive test runners

k6 Advantages:

  • Write tests in JavaScript
  • CLI-first, automation-friendly
  • Built-in metrics and thresholds
  • Efficient resource usage
  • Great for DevOps workflows

When to Use k6

  • Load Testing: Simulate normal user load
  • Stress Testing: Find breaking points
  • Spike Testing: Handle sudden traffic spikes
  • Soak Testing: Long-running stability tests
  • API Performance Testing: Benchmark API endpoints

Getting Started with k6

Installation

# macOS
brew install k6
 
# Windows
choco install k6
 
# Linux
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
 
# Docker
docker pull grafana/k6

Your First k6 Test

// simple-test.js
import http from 'k6/http';
import { check, sleep } from 'k6';
 
export default function () {
  // Make HTTP request
  const response = http.get('https://api.example.com/products');
  
  // Verify response
  check(response, {
    'status is 200': (r) => r.status === 200,
    'response time < 500ms': (r) => r.timings.duration < 500,
  });
  
  // Think time between requests
  sleep(1);
}

Run the test:

# Run with 10 virtual users for 30 seconds
k6 run --vus 10 --duration 30s simple-test.js

Output:

     ✓ status is 200
     ✓ response time < 500ms
 
     checks.........................: 100.00% ✓ 300  ✗ 0
     data_received..................: 1.2 MB  40 kB/s
     data_sent......................: 30 kB   1.0 kB/s
     http_req_blocked...............: avg=1.2ms   min=0s   med=1ms   max=5ms   p(90)=2ms   p(95)=3ms
     http_req_connecting............: avg=800µs   min=0s   med=700µs max=2ms   p(90)=1.2ms p(95)=1.5ms
     http_req_duration..............: avg=245ms   min=180ms med=240ms max=420ms p(90)=290ms p(95)=320ms
     http_req_receiving.............: avg=1.5ms   min=0s   med=1ms   max=8ms   p(90)=3ms   p(95)=4ms
     http_req_sending...............: avg=800µs   min=0s   med=700µs max=2ms   p(90)=1.2ms p(95)=1.4ms
     http_req_waiting...............: avg=243ms   min=178ms med=238ms max=415ms p(90)=288ms p(95)=318ms
     http_reqs......................: 300     10/s
     iteration_duration.............: avg=1.24s   min=1.18s med=1.24s max=1.42s p(90)=1.29s p(95)=1.32s
     iterations.....................: 300     10/s
     vus............................: 10      min=10 max=10
     vus_max........................: 10      min=10 max=10

Load Testing Patterns

1. Constant Load

Maintain steady number of users:

// constant-load.js
import http from 'k6/http';
import { sleep } from 'k6';
 
export const options = {
  vus: 50, // 50 virtual users
  duration: '5m', // for 5 minutes
};
 
export default function () {
  http.get('https://api.example.com/products');
  sleep(1);
}

2. Ramping Load

Gradually increase load:

// ramping-load.js
export const options = {
  stages: [
    { duration: '2m', target: 10 },   // Ramp up to 10 users
    { duration: '5m', target: 10 },   // Stay at 10 users
    { 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 users
    { duration: '5m', target: 100 },  // Stay at 100 users
    { duration: '2m', target: 0 },    // Ramp down to 0
  ],
};
 
export default function () {
  http.get('https://api.example.com/products');
  sleep(1);
}

3. Spike Testing

Sudden traffic increase:

// spike-test.js
export const options = {
  stages: [
    { duration: '1m', target: 10 },    // Normal load
    { duration: '10s', target: 200 },  // Sudden spike!
    { duration: '3m', target: 200 },   // Stay at spike
    { duration: '10s', target: 10 },   // Drop back
    { duration: '1m', target: 10 },    // Recover
  ],
};
 
export default function () {
  http.get('https://api.example.com/products');
  sleep(1);
}

4. Stress Testing

Find breaking point:

// stress-test.js
export const options = {
  stages: [
    { duration: '2m', target: 50 },    // Normal load
    { duration: '5m', target: 50 },
    { duration: '2m', target: 100 },   // High load
    { duration: '5m', target: 100 },
    { duration: '2m', target: 200 },   // Very high
    { duration: '5m', target: 200 },
    { duration: '2m', target: 300 },   // Extreme load
    { duration: '5m', target: 300 },
    { duration: '5m', target: 0 },     // Recovery
  ],
  
  thresholds: {
    http_req_failed: ['rate<0.1'],     // <10% errors
    http_req_duration: ['p(95)<2000'], // 95% under 2s
  },
};

5. Soak Testing

Long-running stability test:

// soak-test.js
export const options = {
  stages: [
    { duration: '5m', target: 50 },    // Ramp up
    { duration: '8h', target: 50 },    // Stay for 8 hours
    { duration: '5m', target: 0 },     // Ramp down
  ],
  
  thresholds: {
    http_req_failed: ['rate<0.01'],
    http_req_duration: ['p(95)<500'],
  },
};

Real-World API Testing

Complete E-commerce API Test

// ecommerce-load-test.js
import http from 'k6/http';
import { check, group, sleep } from 'k6';
import { Rate, Trend } from 'k6/metrics';
 
// Custom metrics
const errorRate = new Rate('errors');
const checkoutDuration = new Trend('checkout_duration');
 
// Configuration
export const options = {
  stages: [
    { duration: '1m', target: 20 },
    { duration: '3m', target: 20 },
    { duration: '1m', target: 0 },
  ],
  
  thresholds: {
    'http_req_duration': ['p(95)<500', 'p(99)<1000'],
    'http_req_failed': ['rate<0.1'],
    'errors': ['rate<0.1'],
    'checkout_duration': ['p(95)<2000'],
  },
};
 
const BASE_URL = 'https://api.example.com';
 
// Test data
const products = [
  { id: 'prod_1', name: 'Laptop' },
  { id: 'prod_2', name: 'Mouse' },
  { id: 'prod_3', name: 'Keyboard' },
];
 
export function setup() {
  // Run once before test
  // Create test user, setup data, etc.
  const loginRes = http.post(`${BASE_URL}/auth/login`, JSON.stringify({
    email: 'loadtest@example.com',
    password: 'Test1234!',
  }), {
    headers: { 'Content-Type': 'application/json' },
  });
  
  return { token: loginRes.json('token') };
}
 
export default function (data) {
  const authHeaders = {
    headers: {
      'Authorization': `Bearer ${data.token}`,
      'Content-Type': 'application/json',
    },
  };
  
  group('Browse Products', function () {
    // List products
    const listRes = http.get(`${BASE_URL}/products`);
    check(listRes, {
      'list products status 200': (r) => r.status === 200,
      'list has products': (r) => r.json('products').length > 0,
    }) || errorRate.add(1);
    
    sleep(1);
    
    // View product details
    const productId = products[Math.floor(Math.random() * products.length)].id;
    const detailRes = http.get(`${BASE_URL}/products/${productId}`);
    check(detailRes, {
      'product detail status 200': (r) => r.status === 200,
      'product has name': (r) => r.json('name') !== undefined,
    }) || errorRate.add(1);
    
    sleep(2);
  });
  
  group('Shopping Cart', function () {
    // Add to cart
    const addRes = http.post(
      `${BASE_URL}/cart/items`,
      JSON.stringify({
        productId: 'prod_1',
        quantity: 1,
      }),
      authHeaders
    );
    
    check(addRes, {
      'add to cart status 201': (r) => r.status === 201,
    }) || errorRate.add(1);
    
    sleep(1);
    
    // View cart
    const cartRes = http.get(`${BASE_URL}/cart`, authHeaders);
    check(cartRes, {
      'view cart status 200': (r) => r.status === 200,
      'cart has items': (r) => r.json('items').length > 0,
    }) || errorRate.add(1);
    
    sleep(2);
  });
  
  group('Checkout', function () {
    const checkoutStart = Date.now();
    
    // Create order
    const orderRes = http.post(
      `${BASE_URL}/orders`,
      JSON.stringify({
        shippingAddress: {
          street: '123 Test St',
          city: 'Test City',
          zip: '12345',
        },
      }),
      authHeaders
    );
    
    check(orderRes, {
      'create order status 201': (r) => r.status === 201,
    }) || errorRate.add(1);
    
    const orderId = orderRes.json('id');
    sleep(1);
    
    // Process payment
    const paymentRes = http.post(
      `${BASE_URL}/payments`,
      JSON.stringify({
        orderId: orderId,
        method: 'credit_card',
        cardNumber: '4242424242424242',
        expiryMonth: '12',
        expiryYear: '2027',
        cvv: '123',
      }),
      authHeaders
    );
    
    check(paymentRes, {
      'payment status 200': (r) => r.status === 200,
      'payment successful': (r) => r.json('status') === 'succeeded',
    }) || errorRate.add(1);
    
    const checkoutTime = Date.now() - checkoutStart;
    checkoutDuration.add(checkoutTime);
    
    sleep(1);
  });
  
  // Think time between iterations
  sleep(Math.random() * 5 + 5); // 5-10 seconds
}
 
export function teardown(data) {
  // Cleanup after test
  // Delete test data, logout, etc.
}

Run with Custom Options

# Run with environment variables
k6 run \
  -e BASE_URL=https://staging.example.com \
  -e USERS=50 \
  --out json=results.json \
  ecommerce-load-test.js
 
# Run with cloud output
k6 run --out cloud ecommerce-load-test.js
 
# Run with InfluxDB output
k6 run --out influxdb=http://localhost:8086/k6 ecommerce-load-test.js

Advanced Features

1. Data Parameterization

Use external data files:

// users.json
[
  {"email": "user1@test.com", "password": "pass1"},
  {"email": "user2@test.com", "password": "pass2"},
  {"email": "user3@test.com", "password": "pass3"}
]
// parameterized-test.js
import { SharedArray } from 'k6/data';
import papaparse from 'https://jslib.k6.io/papaparse/5.1.1/index.js';
 
// Load JSON data
const users = new SharedArray('users', function () {
  return JSON.parse(open('./users.json'));
});
 
// Load CSV data
const products = new SharedArray('products', function () {
  const csvData = open('./products.csv');
  return papaparse.parse(csvData, { header: true }).data;
});
 
export default function () {
  // Use data for each VU
  const user = users[__VU % users.length];
  const product = products[__ITER % products.length];
  
  // Login with user
  const loginRes = http.post(`${BASE_URL}/login`, JSON.stringify({
    email: user.email,
    password: user.password,
  }));
  
  // Get product
  http.get(`${BASE_URL}/products/${product.id}`);
}

2. Scenarios

Advanced execution patterns:

// scenarios.js
export const options = {
  scenarios: {
    // Constant load scenario
    constant_load: {
      executor: 'constant-vus',
      vus: 10,
      duration: '5m',
      tags: { scenario: 'constant' },
    },
    
    // Ramping scenario
    ramping_load: {
      executor: 'ramping-vus',
      startVUs: 0,
      stages: [
        { duration: '1m', target: 20 },
        { duration: '3m', target: 20 },
        { duration: '1m', target: 0 },
      ],
      gracefulRampDown: '30s',
      tags: { scenario: 'ramping' },
    },
    
    // Fixed iterations
    fixed_iterations: {
      executor: 'shared-iterations',
      vus: 10,
      iterations: 200,
      maxDuration: '10m',
    },
    
    // Per-VU iterations
    per_vu_iterations: {
      executor: 'per-vu-iterations',
      vus: 10,
      iterations: 20, // Each VU does 20 iterations
      maxDuration: '10m',
    },
    
    // Constant arrival rate
    constant_arrival_rate: {
      executor: 'constant-arrival-rate',
      rate: 30, // 30 iterations/second
      timeUnit: '1s',
      duration: '5m',
      preAllocatedVUs: 50,
      maxVUs: 100,
    },
  },
};

3. Custom Metrics

Track business metrics:

import { Counter, Gauge, Rate, Trend } from 'k6/metrics';
 
// Define custom metrics
const checkoutAttempts = new Counter('checkout_attempts');
const checkoutSuccesses = new Counter('checkout_successes');
const checkoutFailures = new Counter('checkout_failures');
const checkoutErrorRate = new Rate('checkout_error_rate');
const cartValue = new Trend('cart_value');
const activeUsers = new Gauge('active_users');
 
export default function () {
  activeUsers.add(1);
  
  // Simulate checkout
  checkoutAttempts.add(1);
  
  const response = http.post(`${BASE_URL}/checkout`, payload);
  
  if (response.status === 200) {
    checkoutSuccesses.add(1);
    checkoutErrorRate.add(0);
    cartValue.add(response.json('total'));
  } else {
    checkoutFailures.add(1);
    checkoutErrorRate.add(1);
  }
  
  activeUsers.add(-1);
}

4. Thresholds

Define pass/fail criteria:

export const options = {
  thresholds: {
    // HTTP errors < 1%
    'http_req_failed': ['rate<0.01'],
    
    // 95th percentile < 500ms
    'http_req_duration': ['p(95)<500'],
    
    // 99th percentile < 1s
    'http_req_duration{endpoint:checkout}': ['p(99)<1000'],
    
    // Average < 200ms
    'http_req_duration': ['avg<200'],
    
    // All checks must pass
    'checks': ['rate>0.99'],
    
    // Custom metric threshold
    'checkout_error_rate': ['rate<0.05'],
    
    // Abort test if threshold breached
    'http_req_failed': [
      { threshold: 'rate<0.1', abortOnFail: true }
    ],
  },
};

5. Checks and Validation

Comprehensive response validation:

import { check } from 'k6';
 
export default function () {
  const response = http.get(`${BASE_URL}/api/products/123`);
  
  check(response, {
    // Status checks
    'status is 200': (r) => r.status === 200,
    
    // Header checks
    'has content-type': (r) => r.headers['Content-Type'] !== undefined,
    'is json': (r) => r.headers['Content-Type'].includes('application/json'),
    
    // Body checks
    'has product id': (r) => r.json('id') === '123',
    'has product name': (r) => r.json('name').length > 0,
    'price is valid': (r) => r.json('price') > 0,
    
    // Performance checks
    'response time < 500ms': (r) => r.timings.duration < 500,
    
    // Size checks
    'response size < 1MB': (r) => r.body.length < 1000000,
    
    // Pattern matching
    'body contains product': (r) => r.body.includes('product'),
    
    // Complex validation
    'product is valid': (r) => {
      const product = r.json();
      return product.id && product.name && product.price > 0 && product.stock >= 0;
    },
  });
}

6. Think Time and Pacing

Realistic user behavior:

import { sleep } from 'k6';
 
export default function () {
  // Fixed sleep
  http.get(`${BASE_URL}/products`);
  sleep(1); // Wait 1 second
  
  // Random sleep (realistic variation)
  http.get(`${BASE_URL}/products/123`);
  sleep(Math.random() * 3 + 2); // Random 2-5 seconds
  
  // Think time distribution (more realistic)
  function thinkTime() {
    // Most users: 1-3 seconds
    // Some users: up to 10 seconds
    const rand = Math.random();
    if (rand < 0.7) return 1 + Math.random() * 2; // 70% are quick
    if (rand < 0.9) return 3 + Math.random() * 3; // 20% are medium
    return 6 + Math.random() * 4; // 10% are slow
  }
  
  http.post(`${BASE_URL}/cart`, payload);
  sleep(thinkTime());
}

Integration and Visualization

CI/CD Integration

GitHub Actions:

name: Load Test
 
on:
  push:
    branches: [main]
  schedule:
    - cron: '0 2 * * *' # Daily at 2 AM
 
jobs:
  load-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      
      - name: Run k6 test
        uses: grafana/k6-action@v0.3.0
        with:
          filename: load-tests/api-test.js
          flags: --out json=results.json
          
      - name: Check thresholds
        run: |
          if grep -q '"thresholds_failed"' results.json; then
            echo "Load test thresholds failed!"
            exit 1
          fi
          
      - name: Upload results
        uses: actions/upload-artifact@v3
        with:
          name: k6-results
          path: results.json
          
      - name: Comment on PR
        if: github.event_name == 'pull_request'
        run: |
          # Parse results and comment on PR
          # (implementation details)

Jenkins:

pipeline {
  agent any
  
  stages {
    stage('Load Test') {
      steps {
        sh 'k6 run --out json=results.json load-test.js'
      }
    }
    
    stage('Analyze Results') {
      steps {
        script {
          def results = readJSON file: 'results.json'
          if (results.root_group.checks[0].fails > 0) {
            error('Load test checks failed')
          }
        }
      }
    }
  }
  
  post {
    always {
      archiveArtifacts 'results.json'
    }
  }
}

Grafana Dashboard

Send metrics to InfluxDB and visualize in Grafana:

# Start InfluxDB
docker run -d -p 8086:8086 influxdb:1.8
 
# Run k6 with InfluxDB output
k6 run --out influxdb=http://localhost:8086/k6 test.js

Grafana Dashboard Panels:

  • Request rate (requests/sec)
  • Error rate (%)
  • Response time percentiles (p50, p95, p99)
  • Active VUs
  • Data sent/received
  • Check success rate

k6 Cloud

export const options = {
  ext: {
    loadimpact: {
      projectID: 123456,
      name: 'API Load Test',
      distribution: {
        'amazon:us:ashburn': { loadZone: 'amazon:us:ashburn', percent: 50 },
        'amazon:eu:dublin': { loadZone: 'amazon:eu:dublin', percent: 50 },
      },
    },
  },
};
# Login to k6 cloud
k6 login cloud
 
# Run test in cloud
k6 cloud test.js

Performance Debugging

Identify Slow Endpoints

import { group } from 'k6';
 
export default function () {
  group('Fast Endpoint', function () {
    http.get(`${BASE_URL}/health`); // Should be fast
  });
  
  group('Slow Endpoint', function () {
    http.get(`${BASE_URL}/reports/heavy`); // Might be slow
  });
}
 
// Results will show timing per group
// http_req_duration{group:::Fast Endpoint}: avg=50ms
// http_req_duration{group:::Slow Endpoint}: avg=2000ms

Database Connection Pooling

// Test database under load
export const options = {
  stages: [
    { duration: '1m', target: 100 },
  ],
};
 
export default function () {
  const response = http.get(`${BASE_URL}/api/users`);
  
  check(response, {
    'no connection errors': (r) => 
      !r.body.includes('too many connections') &&
      !r.body.includes('connection pool exhausted'),
  });
}

Memory Leak Detection

// Soak test to find memory leaks
export const options = {
  stages: [
    { duration: '5m', target: 50 },
    { duration: '2h', target: 50 }, // Long run
  ],
};
 
// Monitor server memory while this runs
// If memory grows continuously, there's a leak

Best Practices

  1. Start Small: Begin with low load, increase gradually
  2. Use Realistic Data: Test with production-like data volumes
  3. Think Time: Add realistic delays between requests
  4. Monitor Everything: Track both k6 metrics and server metrics
  5. Set Thresholds: Define clear pass/fail criteria
  6. Test in Isolation: One scenario per test for clarity
  7. Version Control: Store tests in git alongside code
  8. Schedule Regular Tests: Catch performance regressions early
  9. Test in Staging First: Don't load test production without permission
  10. Document Baselines: Know your normal performance levels

Common Pitfalls

  1. Testing from Same Network: Creates artificial bottlenecks
  2. Ignoring Think Time: Unrealistic traffic pattern
  3. Too Many VUs: k6 running machine becomes bottleneck
  4. Not Checking Errors: High throughput but all errors
  5. Testing Wrong Environment: Staging != Production
  6. No Warmup: Cold start skews results
  7. Insufficient Duration: Need time to reach steady state

Next Steps

  1. Install k6 and run your first test
  2. Create baseline tests for key user journeys
  3. Set up dashboards in Grafana
  4. Integrate with CI/CD to run on every deploy
  5. Schedule soak tests to run nightly
  6. Learn k6 browser for frontend performance testing
  7. Explore k6 extensions for custom protocols
  • "Performance Testing Fundamentals" - Core concepts
  • "Monitoring & Observability" - Track production performance
  • "API Testing Strategies" - What to test
  • "CI/CD Integration" - Automate performance testing
  • "Chaos Engineering" - Performance under failure

Conclusion

k6 makes performance testing accessible to developers and QE engineers. By writing tests as code, you can:

  • Version control your load tests
  • Run them in CI/CD pipelines
  • Catch performance regressions early
  • Scale testing across regions
  • Make performance a first-class citizen

Start with simple tests of critical endpoints, establish baselines, and gradually expand coverage. Performance issues are easier to fix before they reach production!

Remember: You don't know if your system can handle 10,000 users until you test it with 10,000 users!

Comments (0)

Loading comments...