Skip to content

API Load Testing: Performance and Stress Testing with k6

DodaTech Updated 2026-06-28 7 min read

In this tutorial, you will learn about API Load Testing: Performance and Stress Testing with k6. We cover key concepts, practical examples, and best practices to help you master this topic.

Load testing validates API performance under expected and peak traffic using simulated virtual users, measuring response times, throughput, error rates, and identifying bottlenecks before they impact real users.

What You'll Learn

How to load test APIs with k6, write test scripts with virtual users and ramp-up stages, set performance thresholds, use custom metrics and checks, run browser-based load tests, integrate with CI/CD, and analyze results to identify bottlenecks.

Why It Matters

Performance issues cause user abandonment and revenue loss. A 1-second delay reduces conversions by 7%. DodaTech load tests every API endpoint to ensure 95th percentile response times under 500ms for 10x peak traffic.

Real-World Use

DodaTech runs a load test simulating 1,000 concurrent users browsing products, adding to cart, and checking out. k6 reports 95th percentile checkout time of 1.2s โ€” above the 500ms threshold. The team identifies the payment API as the bottleneck and optimizes it.

flowchart LR
    A["k6 Script\nVirtual Users"] --> B["Ramp-Up\nStage"]
    B --> C["Steady State\nLoad"]
    C --> D["Ramp-Down\nStage"]
    D --> E["Results\nCollection"]
    E --> F{"Thresholds\nMet?"}
    F -->|Yes| G["Performance\nPasses"]
    F -->|No| H["Bottleneck\nIdentified"]
    H --> I["Optimize &\nRe-test"]
    style A fill:#dbeafe,stroke:#2563eb
    style E fill:#bbf7d0,stroke:#16a34a
    style H fill:#fecaca,stroke:#dc2626

Basic k6 Test Script

// k6 test script for API load testing
import http from 'k6/http';
import { check, sleep } from 'k6';

export const options = {
    vus: 10,           // 10 virtual users
    duration: '30s',   // Run for 30 seconds
    thresholds: {
        http_req_duration: ['p(95)<500'], // 95% of requests under 500ms
        http_req_failed: ['rate<0.01'],   // Less than 1% failure rate
    },
};

export default function () {
    const BASE_URL = 'https://api.staging.dodatech.com/v1';

    // 1. Get users list
    const usersResp = http.get(`${BASE_URL}/users`, {
        headers: { 'Accept': 'application/json' },
    });
    check(usersResp, {
        'users status 200': (r) => r.status === 200,
        'users response < 500ms': (r) => r.timings.duration < 500,
    });

    sleep(1); // Think time between actions

    // 2. Create a new user
    const payload = JSON.stringify({
        email: `loadtest-${__ITER}@example.com`,
        name: `Load Test User ${__ITER}`,
    });
    const createResp = http.post(`${BASE_URL}/users`, payload, {
        headers: { 'Content-Type': 'application/json' },
    });
    check(createResp, {
        'create status 201': (r) => r.status === 201,
        'create response < 1000ms': (r) => r.timings.duration < 1000,
    });

    sleep(1);
}

Ramp-Up Patterns

import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate, Trend } from 'k6/metrics';

// Custom metrics
const errorRate = new Rate('errors');
const checkoutLatency = new Trend('checkout_duration');

export const options = {
    stages: [
        { duration: '2m', target: 100 },   // Ramp up to 100 users
        { duration: '5m', target: 100 },   // Stay at 100 users
        { duration: '2m', target: 200 },   // Ramp up to 200 users
        { duration: '5m', target: 200 },   // Stay at 200 users
        { duration: '2m', target: 0 },     // Ramp down to 0
    ],
    thresholds: {
        http_req_duration: ['p(95)<1000', 'p(99)<2000'],
        errors: ['rate<0.05'],             // Less than 5% errors
        checkout_duration: ['p(95)<2000'], // Checkout-specific threshold
    },
};

export default function () {
    const responses = http.batch([
        ['GET', 'https://api.dodatech.com/v1/products', null, { tags: { name: 'products' } }],
        ['GET', 'https://api.dodatech.com/v1/categories', null, { tags: { name: 'categories' } }],
    ]);

    responses.forEach((res) => {
        check(res, { 'status is 200': (r) => r.status === 200 });
        if (res.status !== 200) {
            errorRate.add(1);
        }
    });

    const checkoutStart = Date.now();
    const checkoutResp = http.post('https://api.dodatech.com/v1/checkout', '{}');
    checkoutLatency.add(Date.now() - checkoutStart);

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

    sleep(Math.random() * 3 + 1); // Random think time
}

Advanced k6 Features

import http from 'k6/http';
import { check, sleep, group } from 'k6';
import { SharedArray } from 'k6/data';
import { randomItem } from 'https://jslib.k6.io/k6-utils/1.2.0/index.js';

// Load test data from JSON file
const users = new SharedArray('users', function () {
    return JSON.parse(open('./test-data/users.json'));
});

export const options = {
    scenarios: {
        // Scenario 1: Normal traffic pattern
        normal_traffic: {
            executor: 'ramping-vus',
            startVUs: 0,
            stages: [
                { duration: '5m', target: 50 },
                { duration: '10m', target: 50 },
                { duration: '5m', target: 0 },
            ],
            gracefulRampDown: '30s',
        },
        // Scenario 2: Spike test
        spike_test: {
            executor: 'ramping-vus',
            startVUs: 0,
            stages: [
                { duration: '2m', target: 0 },
                { duration: '30s', target: 500 },  // Sudden spike
                { duration: '1m', target: 500 },
                { duration: '30s', target: 0 },
            ],
            startTime: '20m',  // Start after normal traffic
        },
    },
    thresholds: {
        http_req_duration: ['p(95)<500', 'avg<200'],
        http_req_failed: ['rate<0.01'],
    },
};

export default function () {
    group('User Browse Flow', function () {
        const user = randomItem(users);

        const products = http.get('https://api.dodatech.com/v1/products', {
            tags: { name: 'browse_products', type: 'browse' },
        });
        check(products, { 'browse products ok': (r) => r.status === 200 });
        sleep(1);

        const productId = products.json()[0]?.id;
        if (productId) {
            const detail = http.get(`https://api.dodatech.com/v1/products/${productId}`);
            check(detail, { 'product detail ok': (r) => r.status === 200 });
        }
    });
}

Running and Analyzing Results

# Install k6
# macOS: brew install k6
# Linux: https://k6.io/docs/getting-started/installation

# Run basic test
k6 run load-test.js

# Run with output to JSON for analysis
k6 run --out json=results.json load-test.js

# Run with HTML report
k6 run --out html=report.html load-test.js

# Run with thresholds summary only
k6 run --quiet load-test.js

# Expected output (partial):
#     โœ“ users status 200
#     โœ“ users response < 500ms
#     โœ“ create status 201
#
#     checks.........................: 100.00% โœ“ 1200      โœ— 0
#     data_received..................: 2.4 MB  78 kB/s
#     data_sent......................: 240 kB  7.9 kB/s
#     http_req_blocked...............: avg=1.2ms   p(95)=4.5ms
#     http_req_connecting............: avg=800ยตs   p(95)=3.2ms
#     http_req_duration..............: avg=245ms   p(95)=420ms โœ“
#     http_req_failed................: 0.00%   โœ“ 0         โœ— 600
#     vus............................: 10      min=10      max=10
#     vus_max........................: 10      min=10      max=10

Common Mistakes

1. Not Using Ramp-Up

Starting with full load immediately can overwhelm the system and cause false failures. Ramp up gradually (2-5 minutes) to let auto-scaling and connection pools warm up.

2. Ignoring Think Time

Without sleep/think time between requests, tests don't simulate real user behavior. A user waits between browsing products, filling forms, and clicking buttons. Add realistic delays (1-5 seconds).

3. Testing Only the Happy Path

Real users encounter errors, network issues, and invalid inputs. Include error scenarios in load tests to verify the system degrades gracefully under stress.

4. Not Setting Thresholds

Without thresholds, you have to manually review results to decide if the test passed. Set thresholds for p(95) latency, error rate, and throughput. Fail the test automatically on threshold violation.

5. Running Load Tests Against Production

Load testing production risks degrading real user experience. Always test against a staging environment that mirrors production infrastructure. If production testing is necessary, run at low traffic times with monitoring alerts.

Practice Questions

  1. What is the difference between load testing and stress testing?
  2. Why is ramp-up important in load tests?
  3. What are k6 thresholds and how do you use them?
  4. How do you simulate realistic user behavior in load tests?

Answers:

  1. Load testing simulates expected traffic (100 users, 500 req/s). Stress testing pushes beyond expected limits (500 users, 2000 req/s) to find breaking points. Spike testing is sudden traffic surges.
  2. Ramp-up gradually increases virtual users, allowing auto-scaling, Connection Pool warming, and cache priming. Without ramp-up, initial requests experience cold-start latency that skews results.
  3. Thresholds are pass/fail criteria: p(95)<500 means 95% of requests must complete in under 500ms. Thresholds make load tests self-validating in CI/CD pipelines.
  4. Use think time (sleep()), random delays (Math.random() * 3 + 1), realistic data from files (SharedArray), varied user flows (browse, search, purchase), and user agents mimicking real browsers.

Challenge: Write a comprehensive k6 load test suite: 3 scenarios (normal traffic, spike, stress), realistic user flows (browse -> product detail -> cart -> checkout), thresholds for p(95) latency and error rate, custom metrics for checkout flow, test data from external JSON file, and CI/CD integration with HTML report generation.

FAQ

How many virtual users should I simulate?

Start with your average concurrent users and multiply by 2-10x. For a new service, start with 50 VUs and increase. Monitor CPU/memory of the target server to determine realistic capacity.

What is the difference between VUs and iterations?

VUs (virtual users) are concurrent simulated users. Each VU runs the default function repeatedly. With 10 VUs and 30s duration, each runs as many iterations as possible within 30s.

How do I test authenticated endpoints in k6?

Add a setup() function that calls login once and stores the token. Each VU uses the stored token. Use __ENV or SharedArray to share the token across VUs.

Can k6 test WebSocket and gRPC?

Yes, k6 supports WebSocket (k6/net/grpc) and gRPC (k6/net/grpc) protocols. Use import { WebSocket } from 'k6/ws' or import grpc from 'k6/net/grpc'.

How do I integrate k6 with CI/CD?

Run k6 run script.js --out json=results.json in CI. Use --thresholds-only to output only pass/fail. Upload results.json as an artifact. Set the build to fail if thresholds are violated.

Mini Project

Build a complete load testing suite for an e-commerce API: k6 scripts for 3 scenarios (browse, search, checkout), ramp-up stages, realistic think times, thresholds for all endpoints, custom metrics for critical flows, test data from JSON files, HTML report generation, and CI/CD integration with failure on threshold breach.

What's Next

Test Data Management โ€” manage test data with fixtures and factories.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro