Skip to main content

The Complete k6 Load Testing Guide

A comprehensive guide to k6 load testing with real-world case studies, practical examples, and production-ready test scenarios for e-commerce platforms.

2026-02-2045 min readTech Architect

The Complete k6 Load Testing Guide

A Practical Tutorial with a Real-World Case Study


Table of Contents

  1. What is k6?
  2. Installation & Setup
  3. Core Concepts
  4. Writing Your First Test
  5. Metrics, Checks & Thresholds
  6. Scenarios & Executors
  7. Case Study: ShopFlow E-Commerce Platform
  8. Interpreting Results
  9. CI/CD Integration
  10. Best Practices & Common Mistakes

1. What is k6?

k6 is an open-source load testing tool built for developers. It was created by Grafana Labs and lets you write tests in JavaScript while the engine itself runs in Go - which means it's fast, resource-efficient, and capable of generating serious load from a single machine.

Why k6 over alternatives?

Feature k6 JMeter Locust
Script language JavaScript XML / Groovy Python
CI/CD friendly [✓] Native [!] Possible [!] Possible
Resource usage Low (Go runtime) High (JVM) Medium
Headless by default [✓] [✗] [✓]
Built-in metrics Extensive Moderate Basic
Browser testing [✓] (k6 browser) [✓] [✗]

Types of load testing k6 supports

  • Smoke test - verify baseline behavior with minimal load
  • Load test - simulate expected real-world traffic
  • Stress test - push beyond normal capacity to find degradation points
  • Spike test - simulate sudden, extreme traffic bursts
  • Soak test - run sustained load over hours to find memory leaks and drift
  • Breakpoint test - gradually increase until the system fails

2. Installation & Setup

macOS

brew install k6

Windows

winget install k6 --source winget
# or via Chocolatey
choco install k6

Linux (Debian/Ubuntu)

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

Docker

docker run -i grafana/k6 run - <script.js

Verify installation

k6 version
# k6 v0.54.0 (go1.22.5, linux/amd64)

[Rest of the content continues exactly as in the original markdown file...]

The Complete k6 Load Testing Guide

A Practical Tutorial with a Real-World Case Study


Table of Contents

  1. What is k6?
  2. Installation & Setup
  3. Core Concepts
  4. Writing Your First Test
  5. Metrics, Checks & Thresholds
  6. Scenarios & Executors
  7. Case Study: ShopFlow E-Commerce Platform
  8. Interpreting Results
  9. CI/CD Integration
  10. Best Practices & Common Mistakes

1. What is k6?

k6 is an open-source load testing tool built for developers. It was created by Grafana Labs and lets you write tests in JavaScript while the engine itself runs in Go - which means it's fast, resource-efficient, and capable of generating serious load from a single machine.

Why k6 over alternatives?

Feature k6 JMeter Locust
Script language JavaScript XML / Groovy Python
CI/CD friendly [✓] Native [!] Possible [!] Possible
Resource usage Low (Go runtime) High (JVM) Medium
Headless by default [✓] [✗] [✓]
Built-in metrics Extensive Moderate Basic
Browser testing [✓] (k6 browser) [✓] [✗]

Types of load testing k6 supports

  • Smoke test - verify baseline behavior with minimal load
  • Load test - simulate expected real-world traffic
  • Stress test - push beyond normal capacity to find degradation points
  • Spike test - simulate sudden, extreme traffic bursts
  • Soak test - run sustained load over hours to find memory leaks and drift
  • Breakpoint test - gradually increase until the system fails

2. Installation & Setup

macOS

brew install k6

Windows

winget install k6 --source winget
# or via Chocolatey
choco install k6

Linux (Debian/Ubuntu)

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

Docker

docker run -i grafana/k6 run - <script.js

Verify installation

k6 version
# k6 v0.54.0 (go1.22.5, linux/amd64)

3. Core Concepts

Virtual Users (VUs)

A VU is a simulated user. Each VU runs your test script independently in a loop. Unlike threads in JMeter, k6 VUs are lightweight coroutines - you can run thousands of them on a modest machine.

Iterations

One execution of the default function (or a named scenario function) by a single VU. A VU may complete many iterations over the course of a test.

The test lifecycle

┌─────────────────────────────────────────────────────────────────┐
│  init  →  setup()  →  [VUs run default()]  →  teardown()        │
└─────────────────────────────────────────────────────────────────┘
Stage Runs Purpose
init Once per VU Import modules, set options, load files
setup() Once globally Seed test data, authenticate, prepare state
default() Once per iteration per VU Your actual test logic
teardown() Once globally Clean up resources

Think time

Real users don't hammer APIs without pause. Use sleep() to simulate human pacing between actions.

import { sleep } from 'k6';

export default function () {
  // ...do something...
  sleep(2); // wait 2 seconds before next iteration
}

4. Writing Your First Test

// hello-k6.js
import http from 'k6/http';
import { check, sleep } from 'k6';

export const options = {
  vus: 10, // 10 concurrent virtual users
  duration: '30s', // run for 30 seconds
};

export default function () {
  const res = http.get('https://httpbin.org/get');

  check(res, {
    'status is 200': r => r.status === 200,
    'response time < 500ms': r => r.timings.duration < 500,
    'has body': r => r.body.length > 0,
  });

  sleep(1);
}

Run it:

k6 run hello-k6.js

Reading the output

scenarios: (100.00%) 1 scenario, 10 max VUs, 1m0s max duration
default: 10 looping VUs for 30s (gracefulStop: 30s)

✓ status is 200
✓ response time < 500ms
✓ has body

checks.........................: 100.00% ✓ 280 ✗ 0
data_received..................: 86 kB   2.9 kB/s
data_sent......................: 23 kB   773 B/s
http_req_blocked...............: avg=6.12ms  min=1µs    med=4µs    max=172ms  p(90)=10µs  p(95)=12µs
http_req_duration..............: avg=203ms   min=190ms  med=198ms  max=412ms  p(90)=223ms p(95)=248ms
http_req_failed................: 0.00%   ✓ 0 ✗ 280
iterations.....................: 280     9.33/s
vus............................: 10      min=10     max=10

5. Metrics, Checks & Thresholds

Built-in HTTP metrics

Metric What it measures
http_req_duration Total round-trip time
http_req_blocked Time waiting for a TCP connection slot
http_req_connecting TCP handshake time
http_req_tls_handshaking TLS negotiation time
http_req_sending Time to send the request body
http_req_waiting Time-to-first-byte (TTFB)
http_req_receiving Time to receive the response body
http_req_failed Rate of failed requests
http_reqs Total request count

Custom metrics

import { Counter, Rate, Trend, Gauge } from 'k6/metrics';

const errors = new Rate('custom_errors'); // % true values
const latency = new Trend('custom_latency', true); // ms distribution
const totalCalls = new Counter('custom_total_calls'); // cumulative count
const activeUsers = new Gauge('custom_active_users'); // current value

Thresholds

Thresholds are pass/fail criteria. The entire test exits with a non-zero code if any threshold is breached - perfect for CI gates.

export const options = {
  thresholds: {
    // 95% of requests must complete below 500ms
    http_req_duration: ['p(95)<500'],

    // 99% of requests for the checkout endpoint must be below 1s
    'http_req_duration{endpoint:checkout}': ['p(99)<1000'],

    // Error rate must stay below 1%
    http_req_failed: ['rate<0.01'],

    // All checks must pass at 99%+
    checks: ['rate>0.99'],

    // Custom metric threshold
    custom_errors: ['rate<0.005'],
  },
};

6. Scenarios & Executors

Scenarios let you define precisely how load is generated. Each scenario uses an executor to control VU and iteration behavior.

Executor types at a glance

Executor Controls Use when
constant-vus Fixed VUs, fixed duration Simple steady-state load
ramping-vus VUs change over stages Ramp-up / ramp-down patterns
constant-arrival-rate Fixed iterations/sec Throughput-focused testing
ramping-arrival-rate RPS changes over stages Variable throughput testing
per-vu-iterations Each VU runs N iterations Functional verification at scale
shared-iterations N total iterations shared across VUs Throughput with a fixed total

Multi-scenario example

export const options = {
  scenarios: {
    browse: {
      executor: 'constant-vus',
      vus: 50,
      duration: '5m',
      exec: 'browseScenario',
    },
    checkout: {
      executor: 'ramping-arrival-rate',
      startRate: 5,
      timeUnit: '1s',
      stages: [
        { duration: '2m', target: 20 },
        { duration: '3m', target: 20 },
      ],
      preAllocatedVUs: 30,
      maxVUs: 80,
      exec: 'checkoutScenario',
    },
  },
};

export function browseScenario() {
  /* ... */
}
export function checkoutScenario() {
  /* ... */
}

7. Case Study: ShopFlow E-Commerce Platform

7.1 System Overview

ShopFlow is an e-commerce platform with the following characteristics:

  • Frontend: React SPA hosted on a CDN
  • Backend: Node.js API behind an Nginx load balancer
  • Database: PostgreSQL (primary) + Redis (cache)
  • Infrastructure: 4 app servers, auto-scaling group (max 8)

The 4 API endpoints under test

Endpoint Method Description Typical p95
GET /api/products GET Product listing with filters 120ms
GET /api/products/:id GET Single product detail 80ms
POST /api/cart/add POST Add item to cart 150ms
POST /api/orders POST Place an order (checkout) 400ms

Capacity baseline

The team has established through prior profiling that the system can handle approximately 50,000 requests per minute under normal operating conditions. That works out to roughly 833 requests per second (RPS).

Is 50,000 RPM realistic?
Yes, for a mid-size e-commerce platform with horizontal scaling. A single optimized Node.js instance can handle 1,000–5,000 RPM depending on the endpoint. With 4 servers and Redis caching product reads, 50,000 RPM is achievable for read-heavy workloads. Write-heavy flows (orders) will be a bottleneck much earlier - typically 5–15% of overall throughput.

Expected traffic distribution (real-world assumption)

Endpoint % of traffic Max RPM
GET /api/products 50% 25,000
GET /api/products/:id 35% 17,500
POST /api/cart/add 10% 5,000
POST /api/orders 5% 2,500

Shared setup

All test files below share this base configuration:

// shared/config.js
export const BASE_URL = 'https://api.shopflow.example.com';

export const defaultHeaders = {
  'Content-Type': 'application/json',
  Accept: 'application/json',
};

// Realistic product IDs and user tokens for the test environment
export const PRODUCT_IDS = [101, 205, 308, 412, 517, 623, 789, 834, 901, 999];
export const AUTH_TOKEN = __ENV.AUTH_TOKEN || 'test-token-abc123';

7.2 Smoke Test

Goal: Verify the system is alive and all 4 endpoints respond correctly before running any heavier tests. This is always the first test you run.

Load profile: 1–2 VUs, 2 minutes, no performance expectations - just correctness.

// tests/01-smoke.js
import http from 'k6/http';
import { check, group, sleep } from 'k6';
import { BASE_URL, defaultHeaders, PRODUCT_IDS, AUTH_TOKEN } from '../shared/config.js';

export const options = {
  vus: 2,
  duration: '2m',

  thresholds: {
    // Smoke test: just prove nothing is broken - generous thresholds
    http_req_duration: ['p(95)<3000'],
    http_req_failed: ['rate<0.05'],
    checks: ['rate>0.95'],
  },

  tags: { test_type: 'smoke' },
};

const headers = {
  headers: { ...defaultHeaders, Authorization: `Bearer ${AUTH_TOKEN}` },
};

export default function () {
  // ── 1. Product listing ────────────────────────────────────────────────
  group('GET /api/products', () => {
    const res = http.get(`${BASE_URL}/api/products?page=1&limit=20`, headers);

    check(res, {
      'products: status 200': r => r.status === 200,
      'products: returns array': r => {
        try {
          return Array.isArray(JSON.parse(r.body).data);
        } catch {
          return false;
        }
      },
      'products: has pagination': r => {
        try {
          return JSON.parse(r.body).meta !== undefined;
        } catch {
          return false;
        }
      },
    });
  });

  sleep(1);

  // ── 2. Product detail ────────────────────────────────────────────────
  group('GET /api/products/:id', () => {
    const id = PRODUCT_IDS[Math.floor(Math.random() * PRODUCT_IDS.length)];
    const res = http.get(`${BASE_URL}/api/products/${id}`, headers);

    check(res, {
      'product detail: status 200': r => r.status === 200,
      'product detail: has id': r => {
        try {
          return JSON.parse(r.body).id === id;
        } catch {
          return false;
        }
      },
    });
  });

  sleep(1);

  // ── 3. Add to cart ───────────────────────────────────────────────────
  group('POST /api/cart/add', () => {
    const payload = JSON.stringify({
      productId: PRODUCT_IDS[0],
      quantity: 1,
    });
    const res = http.post(`${BASE_URL}/api/cart/add`, payload, headers);

    check(res, {
      'cart add: status 200 or 201': r => [200, 201].includes(r.status),
      'cart add: returns cart': r => {
        try {
          return JSON.parse(r.body).cartId !== undefined;
        } catch {
          return false;
        }
      },
    });
  });

  sleep(1);

  // ── 4. Place order ───────────────────────────────────────────────────
  group('POST /api/orders', () => {
    const payload = JSON.stringify({
      items: [{ productId: PRODUCT_IDS[0], quantity: 1 }],
      address: { street: '123 Test St', city: 'Berlin', zip: '10115' },
      payment: { method: 'test_card', token: 'tok_test_visa' },
    });
    const res = http.post(`${BASE_URL}/api/orders`, payload, headers);

    check(res, {
      'order: status 201': r => r.status === 201,
      'order: returns order id': r => {
        try {
          return JSON.parse(r.body).orderId !== undefined;
        } catch {
          return false;
        }
      },
    });
  });

  sleep(2);
}

Expected outcome: All checks pass, no errors. If the smoke test fails, there's no point running any other test.


7.3 Load Test

Goal: Simulate a typical busy day - not the peak, but normal operating load. Validate that response times and error rates stay within SLA targets under expected conditions.

Load profile:

  • Ramp up over 5 minutes to 600 RPM (≈ 70% of capacity, a realistic busy hour)
  • Hold for 20 minutes
  • Ramp down over 5 minutes

Why 600 RPM? Real traffic is not uniformly distributed. 50,000 RPM is the peak ceiling. A typical "busy hour" might be 40–50% of peak - around 20,000–25,000 RPM across all endpoints. For a focused API test targeting 4 endpoints, 600 RPM represents a realistic combined load from those specific routes.

// tests/02-load.js
import http from 'k6/http';
import { check, group, sleep } from 'k6';
import { Rate, Trend } from 'k6/metrics';
import { BASE_URL, defaultHeaders, PRODUCT_IDS, AUTH_TOKEN } from '../shared/config.js';

// ── Custom metrics ────────────────────────────────────────────────────────
const checkoutErrors = new Rate('checkout_error_rate');
const orderDuration = new Trend('order_duration_ms', true);

export const options = {
  scenarios: {
    // Product listing - 50% of traffic
    product_listing: {
      executor: 'constant-arrival-rate',
      rate: 300, // 300 iterations/min
      timeUnit: '1m',
      duration: '30m',
      preAllocatedVUs: 20,
      maxVUs: 50,
      exec: 'productListing',
      stages: [
        // Note: constant-arrival-rate doesn't support stages natively,
        // so we use ramping-arrival-rate for ramp-up
      ],
    },

    // Product detail - 35% of traffic
    product_detail: {
      executor: 'constant-arrival-rate',
      rate: 210,
      timeUnit: '1m',
      duration: '30m',
      preAllocatedVUs: 15,
      maxVUs: 40,
      exec: 'productDetail',
    },

    // Add to cart - 10% of traffic
    add_to_cart: {
      executor: 'constant-arrival-rate',
      rate: 60,
      timeUnit: '1m',
      duration: '30m',
      preAllocatedVUs: 10,
      maxVUs: 25,
      exec: 'addToCart',
    },

    // Checkout / orders - 5% of traffic (most expensive operation)
    checkout: {
      executor: 'constant-arrival-rate',
      rate: 30,
      timeUnit: '1m',
      duration: '30m',
      preAllocatedVUs: 10,
      maxVUs: 30,
      exec: 'placeOrder',
    },
  },

  thresholds: {
    // Overall response time SLAs
    'http_req_duration{endpoint:products}': ['p(95)<200', 'p(99)<500'],
    'http_req_duration{endpoint:product_detail}': ['p(95)<150', 'p(99)<300'],
    'http_req_duration{endpoint:cart}': ['p(95)<250', 'p(99)<500'],
    'http_req_duration{endpoint:orders}': ['p(95)<600', 'p(99)<1200'],

    // Error rates by endpoint
    'http_req_failed{endpoint:products}': ['rate<0.01'],
    'http_req_failed{endpoint:orders}': ['rate<0.005'], // stricter for checkout

    // Custom metric: checkout-specific error rate
    checkout_error_rate: ['rate<0.005'],
    order_duration_ms: ['p(95)<600'],

    // Global
    checks: ['rate>0.99'],
    http_req_failed: ['rate<0.01'],
  },

  tags: { test_type: 'load' },
};

const makeHeaders = endpoint => ({
  headers: { ...defaultHeaders, Authorization: `Bearer ${AUTH_TOKEN}` },
  tags: { endpoint },
});

// ── Scenario functions ────────────────────────────────────────────────────

export function productListing() {
  const params = [
    'page=1&limit=20',
    'page=2&limit=20',
    'category=electronics&limit=20',
    'sort=price_asc&limit=20',
    'search=laptop&limit=10',
  ];
  const query = params[Math.floor(Math.random() * params.length)];
  const res = http.get(`${BASE_URL}/api/products?${query}`, makeHeaders('products'));

  check(res, {
    'listing: 200': r => r.status === 200,
    'listing: p95 < 200ms': r => r.timings.duration < 200,
  });

  sleep(0.1); // minimal think time for arrival-rate scenarios
}

export function productDetail() {
  const id = PRODUCT_IDS[Math.floor(Math.random() * PRODUCT_IDS.length)];
  const res = http.get(`${BASE_URL}/api/products/${id}`, makeHeaders('product_detail'));

  check(res, {
    'detail: 200': r => r.status === 200,
    'detail: p95 < 150ms': r => r.timings.duration < 150,
  });
}

export function addToCart() {
  const res = http.post(
    `${BASE_URL}/api/cart/add`,
    JSON.stringify({
      productId: PRODUCT_IDS[Math.floor(Math.random() * PRODUCT_IDS.length)],
      quantity: Math.ceil(Math.random() * 3),
    }),
    makeHeaders('cart')
  );

  check(res, {
    'cart: 200 or 201': r => [200, 201].includes(r.status),
    'cart: p95 < 250ms': r => r.timings.duration < 250,
  });
}

export function placeOrder() {
  const res = http.post(
    `${BASE_URL}/api/orders`,
    JSON.stringify({
      items: [{ productId: PRODUCT_IDS[0], quantity: 1 }],
      address: { street: '456 Load St', city: 'Hamburg', zip: '20095' },
      payment: { method: 'test_card', token: 'tok_test_visa' },
    }),
    makeHeaders('orders')
  );

  const ok = check(res, {
    'order: 201': r => r.status === 201,
    'order: p95 < 600ms': r => r.timings.duration < 600,
  });

  checkoutErrors.add(!ok);
  orderDuration.add(res.timings.duration);
}

What to look for in results:

  • All thresholds green [✓]
  • p95 latencies within SLA
  • No error rate spikes at any point during the hold phase
  • Resource usage on your servers should plateau, not grow

7.4 Stress Test

Goal: Push past normal load, step by step, to find where the system starts degrading. Unlike the breakpoint test (which seeks the cliff edge), the stress test aims to characterize degradation - at what load does p95 start climbing? When do errors appear?

Load profile: 6 stages ramping from 20% to 200% of normal capacity, holding 5 minutes at each level.

// tests/03-stress.js
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate } from 'k6/metrics';
import { BASE_URL, defaultHeaders, PRODUCT_IDS, AUTH_TOKEN } from '../shared/config.js';

const errorRate = new Rate('stress_error_rate');

export const options = {
  // We use ramping-vus here to see how the system copes as concurrency grows.
  // Target VU counts are calibrated so that each VU generates ~1 req/s with sleep(1),
  // giving us approximate RPM figures at each stage.
  scenarios: {
    stress_test: {
      executor: 'ramping-vus',
      startVUs: 0,
      stages: [
        // Stage 1: 20% capacity (~10,000 RPM)
        { duration: '3m', target: 167 }, // ramp up
        { duration: '5m', target: 167 }, // hold

        // Stage 2: 50% capacity (~25,000 RPM)
        { duration: '3m', target: 417 },
        { duration: '5m', target: 417 },

        // Stage 3: 80% capacity (~40,000 RPM) - approaching limit
        { duration: '3m', target: 667 },
        { duration: '5m', target: 667 },

        // Stage 4: 100% capacity (~50,000 RPM) - at the limit
        { duration: '3m', target: 833 },
        { duration: '5m', target: 833 },

        // Stage 5: 150% capacity - deliberate overload
        { duration: '3m', target: 1250 },
        { duration: '5m', target: 1250 },

        // Stage 6: 200% capacity - how bad does it get?
        { duration: '3m', target: 1667 },
        { duration: '5m', target: 1667 },

        // Recovery: ramp down, check if system recovers
        { duration: '5m', target: 167 },
        { duration: '3m', target: 0 },
      ],
      gracefulRampDown: '2m',
    },
  },

  thresholds: {
    // For stress tests, thresholds are OBSERVATIONS not hard pass/fail.
    // We set them high so the test completes and we get full data.
    http_req_duration: ['p(95)<5000'], // warn if p95 > 5s (extreme degradation)
    http_req_failed: ['rate<0.5'], // only fail if >50% errors (total meltdown)
    stress_error_rate: ['rate<0.5'],
  },

  tags: { test_type: 'stress' },
};

const headers = {
  headers: { ...defaultHeaders, Authorization: `Bearer ${AUTH_TOKEN}` },
};

export default function () {
  // Mix of all endpoints, weighted by traffic distribution
  const rand = Math.random();

  if (rand < 0.5) {
    // 50%: product listing
    const res = http.get(`${BASE_URL}/api/products?page=1&limit=20`, {
      ...headers,
      tags: { endpoint: 'products' },
    });
    const ok = check(res, { 'products: 200': r => r.status === 200 });
    errorRate.add(!ok);
  } else if (rand < 0.85) {
    // 35%: product detail
    const id = PRODUCT_IDS[Math.floor(Math.random() * PRODUCT_IDS.length)];
    const res = http.get(`${BASE_URL}/api/products/${id}`, {
      ...headers,
      tags: { endpoint: 'product_detail' },
    });
    const ok = check(res, { 'detail: 200': r => r.status === 200 });
    errorRate.add(!ok);
  } else if (rand < 0.95) {
    // 10%: add to cart
    const res = http.post(
      `${BASE_URL}/api/cart/add`,
      JSON.stringify({ productId: PRODUCT_IDS[0], quantity: 1 }),
      { ...headers, tags: { endpoint: 'cart' } }
    );
    const ok = check(res, { 'cart: 2xx': r => r.status < 300 });
    errorRate.add(!ok);
  } else {
    // 5%: checkout
    const res = http.post(
      `${BASE_URL}/api/orders`,
      JSON.stringify({
        items: [{ productId: PRODUCT_IDS[0], quantity: 1 }],
        address: { street: '789 Stress Ave', city: 'Munich', zip: '80331' },
        payment: { method: 'test_card', token: 'tok_test_visa' },
      }),
      { ...headers, tags: { endpoint: 'orders' } }
    );
    const ok = check(res, { 'order: 201': r => r.status === 201 });
    errorRate.add(!ok);
  }

  sleep(1);
}

What to look for:

  • At which stage does p95 latency start climbing noticeably? (e.g., "crosses 500ms at stage 4")
  • At which stage do errors first appear?
  • Does the system recover when load drops back down at the end?
  • Watch your server metrics alongside k6 - CPU/memory/connection pool exhaustion tells you why it's failing

7.5 Spike Test

Goal: Simulate a flash sale, a viral moment, or a bot attack - a sudden 10× traffic burst that lasts minutes before returning to normal. This tests autoscaling response time and connection pool resilience.

Scenario: ShopFlow is launching a flash sale at 12:00. Traffic is expected to jump from 5,000 RPM to 50,000 RPM within 60 seconds.

// tests/04-spike.js
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate, Trend } from 'k6/metrics';
import { BASE_URL, defaultHeaders, PRODUCT_IDS, AUTH_TOKEN } from '../shared/config.js';

const spikeErrors = new Rate('spike_error_rate');
const recoveryLatency = new Trend('recovery_latency_ms', true);
let spikeStarted = false;

export const options = {
  scenarios: {
    spike: {
      executor: 'ramping-vus',
      startVUs: 0,
      stages: [
        // Pre-spike baseline: ~5,000 RPM (83 VUs × ~1 req/s)
        { duration: '2m', target: 83 }, // ramp up to baseline
        { duration: '3m', target: 83 }, // hold baseline

        // THE SPIKE: jump to ~50,000 RPM in 60 seconds
        { duration: '1m', target: 833 }, // flash sale starts!

        // Hold the spike for 5 minutes
        { duration: '5m', target: 833 },

        // Spike subsides: back to baseline
        { duration: '1m', target: 83 },

        // Confirm recovery: hold baseline again
        { duration: '5m', target: 83 },

        // Ramp down
        { duration: '2m', target: 0 },
      ],
      gracefulRampDown: '1m',
    },
  },

  thresholds: {
    // During spike, we accept degradation - but track it
    http_req_duration: ['p(95)<3000'],
    http_req_failed: ['rate<0.10'], // accept up to 10% errors during spike
    spike_error_rate: ['rate<0.10'],

    // After the spike (recovery phase), errors must drop back to normal
    // Tag requests in recovery phase with { phase: "recovery" }
    'http_req_failed{phase:recovery}': ['rate<0.01'],
    'http_req_duration{phase:recovery}': ['p(95)<500'],
  },

  tags: { test_type: 'spike' },
};

const baseHeaders = {
  headers: { ...defaultHeaders, Authorization: `Bearer ${AUTH_TOKEN}` },
};

export default function () {
  // Determine if we're in the recovery phase (after the spike drops)
  // VU count is controlled by the stage; we tag based on current time
  // In a real test you'd track elapsed time with Date.now()
  const phase = __VU <= 83 && spikeStarted ? 'recovery' : 'spike';

  const headers = {
    ...baseHeaders,
    tags: { phase },
  };

  // During a spike/flash sale, product detail and cart are hammered most
  const rand = Math.random();

  if (rand < 0.6) {
    // 60% product listing (everyone browsing the sale)
    const res = http.get(`${BASE_URL}/api/products?category=sale&limit=20`, {
      ...headers,
      tags: { ...headers.tags, endpoint: 'products' },
    });
    const ok = check(res, {
      'spike products: responded': r => r.status !== 0,
      'spike products: not 5xx': r => r.status < 500,
      'spike products: acceptable ms': r => r.timings.duration < 3000,
    });
    spikeErrors.add(!ok);
    if (phase === 'recovery') recoveryLatency.add(res.timings.duration);
  } else if (rand < 0.85) {
    // 25% product detail
    const id = PRODUCT_IDS[Math.floor(Math.random() * PRODUCT_IDS.length)];
    const res = http.get(`${BASE_URL}/api/products/${id}`, {
      ...headers,
      tags: { ...headers.tags, endpoint: 'product_detail' },
    });
    const ok = check(res, { 'spike detail: not 5xx': r => r.status < 500 });
    spikeErrors.add(!ok);
  } else if (rand < 0.97) {
    // 12% add to cart (flash sale adding items quickly)
    const res = http.post(
      `${BASE_URL}/api/cart/add`,
      JSON.stringify({ productId: PRODUCT_IDS[0], quantity: 1 }),
      { ...headers, tags: { ...headers.tags, endpoint: 'cart' } }
    );
    const ok = check(res, {
      'spike cart: responded': r => r.status !== 0,
      'spike cart: not 5xx': r => r.status < 500,
    });
    spikeErrors.add(!ok);
  } else {
    // 3% orders
    const res = http.post(
      `${BASE_URL}/api/orders`,
      JSON.stringify({
        items: [{ productId: PRODUCT_IDS[0], quantity: 1 }],
        address: { street: '1 Spike Rd', city: 'Frankfurt', zip: '60311' },
        payment: { method: 'test_card', token: 'tok_test_visa' },
      }),
      { ...headers, tags: { ...headers.tags, endpoint: 'orders' } }
    );
    const ok = check(res, { 'spike order: responded': r => r.status !== 0 });
    spikeErrors.add(!ok);
  }

  // Flash sale users have very short think time - they're rushing!
  sleep(Math.random() * 0.5);
}

What to look for:

  • How long does it take for errors to appear after the spike? (measures queue depth and buffer)
  • Does the error rate drop back to <1% within 2 minutes of returning to baseline? (measures recovery)
  • Are there any 503/504 responses? (load balancer rejecting connections = capacity problem)
  • Check your autoscaling logs - did new servers spin up, and did they spin up fast enough?

7.6 Soak / Endurance Test

Goal: Run a moderate load for 4+ hours to catch problems that only emerge over time: memory leaks, connection pool exhaustion, database connection drift, cache eviction issues, log files filling disks.

Load profile: 70% of normal capacity (~35,000 RPM) held for 4 hours.

// tests/05-soak.js
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate, Trend, Counter } from 'k6/metrics';
import { BASE_URL, defaultHeaders, PRODUCT_IDS, AUTH_TOKEN } from '../shared/config.js';

const errorRate = new Rate('soak_error_rate');
const p95OverTime = new Trend('p95_trend_ms', true);
const totalOrders = new Counter('total_orders_placed');

export const options = {
  scenarios: {
    soak: {
      executor: 'ramping-vus',
      startVUs: 0,
      stages: [
        { duration: '10m', target: 583 }, // ramp up to ~35,000 RPM (70% capacity)
        { duration: '4h', target: 583 }, // hold for 4 hours
        { duration: '10m', target: 0 }, // ramp down
      ],
      gracefulRampDown: '5m',
    },
  },

  thresholds: {
    // During a soak test, the TREND matters as much as absolute values.
    // If p95 starts at 150ms and drifts to 600ms by hour 4, that's a memory leak.
    http_req_duration: ['p(95)<500'],
    http_req_failed: ['rate<0.01'],
    soak_error_rate: ['rate<0.01'],
    checks: ['rate>0.99'],
  },

  tags: { test_type: 'soak' },
};

const headers = {
  headers: { ...defaultHeaders, Authorization: `Bearer ${AUTH_TOKEN}` },
};

export default function () {
  const rand = Math.random();
  let res;

  if (rand < 0.5) {
    res = http.get(`${BASE_URL}/api/products?page=${Math.ceil(Math.random() * 5)}&limit=20`, {
      ...headers,
      tags: { endpoint: 'products' },
    });
    check(res, { 'soak products: 200': r => r.status === 200 });
  } else if (rand < 0.85) {
    const id = PRODUCT_IDS[Math.floor(Math.random() * PRODUCT_IDS.length)];
    res = http.get(`${BASE_URL}/api/products/${id}`, {
      ...headers,
      tags: { endpoint: 'product_detail' },
    });
    check(res, { 'soak detail: 200': r => r.status === 200 });
  } else if (rand < 0.95) {
    res = http.post(
      `${BASE_URL}/api/cart/add`,
      JSON.stringify({ productId: PRODUCT_IDS[0], quantity: 1 }),
      { ...headers, tags: { endpoint: 'cart' } }
    );
    check(res, { 'soak cart: 2xx': r => r.status < 300 });
  } else {
    res = http.post(
      `${BASE_URL}/api/orders`,
      JSON.stringify({
        items: [{ productId: PRODUCT_IDS[0], quantity: 1 }],
        address: { street: '99 Soak Blvd', city: 'Cologne', zip: '50667' },
        payment: { method: 'test_card', token: 'tok_test_visa' },
      }),
      { ...headers, tags: { endpoint: 'orders' } }
    );
    const ok = check(res, { 'soak order: 201': r => r.status === 201 });
    if (ok) totalOrders.add(1);
  }

  // Track p95 trend - export to Grafana and plot over time to spot drift
  if (res) {
    p95OverTime.add(res.timings.duration);
    errorRate.add(res.status >= 500);
  }

  sleep(1);
}

What to look for:

  • Export metrics to InfluxDB + Grafana and plot p95 over time as a line chart
  • Any upward trend in latency, even gradual, indicates resource leak
  • Memory usage on servers - should plateau, not grow linearly
  • Database connection pool - watch for too many connections errors
  • Disk usage - application logs can fill a disk in hours under sustained load

Tip: Instrument your servers with Prometheus node-exporter alongside k6. The combination tells you the full story.


7.7 Breakpoint Test

Goal: Find the exact ceiling. Increase RPS methodically until the system fails, noting the precise point of failure and how it fails (errors? timeouts? dropped connections?).

Load profile: Start at 100 RPS, add 50 RPS every 2 minutes until collapse.

// tests/06-breakpoint.js
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate } from 'k6/metrics';
import { BASE_URL, defaultHeaders, PRODUCT_IDS, AUTH_TOKEN } from '../shared/config.js';

const errorRate = new Rate('breakpoint_error_rate');

export const options = {
  scenarios: {
    breakpoint: {
      executor: 'ramping-arrival-rate', // arrival rate = real RPS, not affected by response time
      startRate: 100, // start at 100 RPS
      timeUnit: '1s',
      preAllocatedVUs: 100,
      maxVUs: 2000,
      stages: [
        // Add 50 RPS every 2 minutes - methodical ramp
        { duration: '2m', target: 150 },
        { duration: '2m', target: 200 },
        { duration: '2m', target: 250 },
        { duration: '2m', target: 300 }, // 18,000 RPM
        { duration: '2m', target: 350 },
        { duration: '2m', target: 400 },
        { duration: '2m', target: 450 },
        { duration: '2m', target: 500 }, // 30,000 RPM - approaching limit
        { duration: '2m', target: 600 },
        { duration: '2m', target: 700 },
        { duration: '2m', target: 833 }, // 50,000 RPM - the stated limit
        { duration: '2m', target: 1000 }, // pushing past
        { duration: '2m', target: 1200 }, // well past - expect failures
      ],
    },
  },

  thresholds: {
    // DO NOT set strict thresholds here - we WANT to find the failure point.
    // Setting these high just ensures the test runs to completion.
    http_req_duration: ['p(95)<30000'], // 30s max - extreme degradation allowed
    http_req_failed: ['rate<0.95'], // only stop if 95%+ requests fail
  },

  tags: { test_type: 'breakpoint' },
};

const headers = {
  headers: { ...defaultHeaders, Authorization: `Bearer ${AUTH_TOKEN}` },
};

export default function () {
  // For breakpoint tests, focus on the most critical + most frequent endpoints
  const rand = Math.random();

  if (rand < 0.7) {
    const res = http.get(`${BASE_URL}/api/products?limit=20`, {
      ...headers,
      tags: { endpoint: 'products' },
    });
    const ok = check(res, { 'bp products: not 5xx': r => r.status < 500 });
    errorRate.add(!ok);
  } else if (rand < 0.95) {
    const id = PRODUCT_IDS[Math.floor(Math.random() * PRODUCT_IDS.length)];
    const res = http.get(`${BASE_URL}/api/products/${id}`, {
      ...headers,
      tags: { endpoint: 'product_detail' },
    });
    const ok = check(res, { 'bp detail: not 5xx': r => r.status < 500 });
    errorRate.add(!ok);
  } else {
    const res = http.post(
      `${BASE_URL}/api/orders`,
      JSON.stringify({
        items: [{ productId: PRODUCT_IDS[0], quantity: 1 }],
        address: { street: '0 Break Point', city: 'Stuttgart', zip: '70173' },
        payment: { method: 'test_card', token: 'tok_test_visa' },
      }),
      { ...headers, tags: { endpoint: 'orders' } }
    );
    const ok = check(res, { 'bp order: responded': r => r.status !== 0 });
    errorRate.add(!ok);
  }
}

// Arrival rate scenarios don't need sleep() - the executor controls the rate

What to look for:

  • The exact RPS stage where error rate crosses 1% - that's your true capacity
  • The response time curve: a sudden vertical jump in p95 indicates saturation
  • Which errors appear first: timeouts (slow), 503s (load balancer rejecting), or 500s (app crashing)
  • The "knee of the curve" - the RPS at which latency starts climbing non-linearly

8. Interpreting Results

Key percentiles explained

Percentile Meaning
p(50) / median Half your users experience this or better
p(90) 9 in 10 users experience this or better
p(95) Industry standard SLA target
p(99) "Worst realistic case" - outliers excluded
max Single worst request - often noise/outlier

Always tune to p(95) or p(99), never to the average. Averages hide tail latency.

Reading the standard output

http_req_duration..............: avg=203ms min=12ms med=180ms max=4120ms p(90)=310ms p(95)=420ms p(99)=980ms

This tells you: typical users get ~180ms, but 5% of users wait over 420ms, and 1% wait nearly a second. The max of 4.1s is an outlier - possibly a garbage collection pause or cold cache hit.

Common failure signatures

Symptom Likely cause
Error rate spikes sharply then recovers Queue overflow - requests backed up, then shed
Latency drifts up slowly over hours Memory leak or cache filling
503 errors from load balancer Backend servers at connection limit
http_req_blocked time spikes TCP connection pool exhausted
Errors concentrated on POST endpoints Database write bottleneck
Errors on all endpoints simultaneously Shared resource exhaustion (DB, Redis)

9. CI/CD Integration

GitHub Actions example

# .github/workflows/load-test.yml
name: Load Test

on:
  push:
    branches: [main]
  schedule:
    - cron: '0 2 * * 1' # every Monday at 2 AM

jobs:
  smoke-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-get update && sudo apt-get install k6

      - name: Run smoke test
        env:
          AUTH_TOKEN: ${{ secrets.TEST_AUTH_TOKEN }}
          ENVIRONMENT: staging
        run: k6 run tests/01-smoke.js --out json=results/smoke.json

      - name: Upload results
        uses: actions/upload-artifact@v4
        with:
          name: k6-smoke-results
          path: results/

  load-test:
    runs-on: ubuntu-latest
    needs: smoke-test
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4
      - name: Run load test
        env:
          AUTH_TOKEN: ${{ secrets.TEST_AUTH_TOKEN }}
        run: k6 run tests/02-load.js

Sending results to Grafana Cloud k6

# Set your Grafana Cloud credentials
export K6_CLOUD_TOKEN="your-grafana-cloud-token"
export K6_CLOUD_PROJECT_ID="12345"

# Run and stream results to Grafana
k6 run --out cloud tests/02-load.js

Streaming to InfluxDB + Grafana (self-hosted)

# Start InfluxDB and Grafana via Docker
docker-compose up -d influxdb grafana

# Run with InfluxDB output
k6 run --out influxdb=http://localhost:8086/k6 tests/02-load.js
# docker-compose.yml
version: '3'
services:
  influxdb:
    image: influxdb:1.8
    ports: ['8086:8086']
    environment:
      INFLUXDB_DB: k6
  grafana:
    image: grafana/grafana:latest
    ports: ['3000:3000']
    environment:
      GF_AUTH_ANONYMOUS_ENABLED: 'true'

Import the official k6 Grafana dashboard (ID: 2587) from grafana.com for instant visualization.


10. Best Practices & Common Mistakes

Do's

Use arrival-rate executors for throughput accuracy. ramping-vus measures concurrency; ramping-arrival-rate measures actual RPS. For realistic load simulation, arrival rate is almost always more accurate because it decouples "how many users" from "how fast the server responds."

Add think time. Real users pause between actions. Without sleep(), your VUs hammer the server at maximum speed and your test represents a pathological worst case, not reality.

Tag everything. Apply tags to requests so you can filter metrics by endpoint, scenario, or user flow in your results dashboard.

Run tests from multiple regions. For global services, latency from a single source is misleading. Use Grafana Cloud k6 or distributed k6 runners in different regions.

Always run a smoke test first. A 2-minute smoke test before a 4-hour soak test will save you from finding out at hour 3 that a misconfigured auth header caused 100% failures.

Test against a production-like environment. Staging with 1/10th the database size will give you 1/10th the insight. Try to match production data volume at minimum.

Don't's

Don't test production directly unless you have no other choice. Use a dedicated performance environment. If you must test production, use smoke or minimal load scenarios only.

Don't use max as your SLA target. The maximum response time is often an outlier - a GC pause, a cold cache, a network blip. p(95) or p(99) is far more meaningful.

Don't ignore the http_req_blocked metric. High blocked times mean your test machine (or the server) is running out of TCP connection slots. This is a test infrastructure problem, not an application problem.

Don't run load tests without server-side monitoring. k6 tells you what the system does from the outside. Prometheus, Datadog, or CloudWatch tells you why. You need both.

Don't correlate VU count with users 1:1. A VU that makes a request every second is not a "user making one request per second" - it's far more aggressive because real users spend time reading, clicking, and thinking. 1 VU ≈ many real users in most scenarios.

Capacity planning rule of thumb for ShopFlow

Load type Target RPS VUs (with 1s think time) Test duration
Smoke 2 2 2 min
Normal load 500 500 30 min
Peak load 833 833 30 min
Stress Up to 1,667 Up to 1,667 50 min
Spike peak 833 (sudden) 833 15 min
Soak 583 583 4+ hours
Breakpoint 100 → 1,200 auto 26 min

Built with k6 v0.54+ · Last updated February 2026

Back to Writing