Skip to main content
API load testing helps you understand how your backend services perform under various traffic conditions. This guide covers best practices for testing APIs with k6, from simple endpoint tests to complex end-to-end workflows.

Planning Your API Tests

Before writing test scripts, consider three key questions:
1

What to test

Which flows or components do you want to test? Start with isolated endpoints, then progress to integrated APIs and end-to-end flows.
2

How to run

Will you test locally, in CI/CD, or from specific geographic locations? Consider where your load generators should be located.
3

Performance criteria

What determines acceptable performance? Define SLOs for latency, error rates, and throughput.

Testing Strategy Progression

API testing typically follows the testing pyramid:

1. Isolated API Testing

Test individual endpoints to establish baseline performance:
import http from 'k6/http';

export default function () {
  const payload = JSON.stringify({
    name: 'lorem',
    surname: 'ipsum',
  });
  const headers = { 'Content-Type': 'application/json' };
  http.post('https://quickpizza.grafana.com/api/post', payload, { headers });
}

2. Integrated API Testing

Test APIs that interact with other internal or external services:
import http from 'k6/http';
import { check } from 'k6';

export default function () {
  // Create a resource
  const createRes = http.post('https://api.example.com/users', JSON.stringify({
    username: 'testuser',
    email: '[email protected]',
  }), { headers: { 'Content-Type': 'application/json' } });
  
  check(createRes, { 'user created': (r) => r.status === 201 });
  
  // Retrieve the resource
  if (createRes.status === 201) {
    const userId = createRes.json('id');
    http.get(`https://api.example.com/users/${userId}`);
  }
}

3. End-to-End API Flows

Simulate complete user journeys across multiple endpoints:
import http from 'k6/http';
import { check, sleep } from 'k6';

export default function () {
  // User authentication
  const loginRes = http.post('https://api.example.com/auth/login', 
    JSON.stringify({ username: 'user', password: 'pass' }));
  
  const token = loginRes.json('token');
  const headers = { 
    'Authorization': `Bearer ${token}`,
    'Content-Type': 'application/json'
  };
  
  // Browse products
  http.get('https://api.example.com/products', { headers });
  sleep(1);
  
  // Add to cart
  http.post('https://api.example.com/cart', 
    JSON.stringify({ productId: 123, quantity: 1 }), { headers });
  sleep(1);
  
  // Checkout
  http.post('https://api.example.com/checkout', 
    JSON.stringify({ cartId: 456 }), { headers });
}

Modeling API Load

Choose the right load model based on your test goals:

Virtual Users (Concurrent Users)

Use when simulating user behavior with think time:
export const options = {
  vus: 50,
  duration: '30s',
};

export default function () {
  http.post('https://quickpizza.grafana.com/api/post', 
    JSON.stringify({ name: 'lorem', surname: 'ipsum' }),
    { headers: { 'Content-Type': 'application/json' } });
}

Request Rate (Requests Per Second)

Use for throughput-based testing of isolated endpoints:
export const options = {
  scenarios: {
    constant_rps: {
      executor: 'constant-arrival-rate',
      rate: 50,           // 50 iterations per timeUnit
      timeUnit: '1s',     // Per second
      duration: '30s',
      preAllocatedVUs: 50,
    },
  },
};

export default function () {
  http.post('https://quickpizza.grafana.com/api/post',
    JSON.stringify({ name: 'lorem', surname: 'ipsum' }),
    { headers: { 'Content-Type': 'application/json' } });
}
Calculating request rate: If each iteration makes multiple requests, divide your target RPS by requests per iteration.For 50 RPS with 2 requests per iteration: rate = 50 / 2 = 25

Validating Responses with Checks

Checks verify API correctness under load:
import { check } from 'k6';
import http from 'k6/http';

export default function () {
  const payload = JSON.stringify({
    name: 'lorem',
    surname: 'ipsum',
  });
  const headers = { 'Content-Type': 'application/json' };
  const res = http.post('https://quickpizza.grafana.com/api/post', payload, { headers });

  check(res, {
    'status is 200': (r) => r.status === 200,
    'correct content type': (r) => r.headers['Content-Type'] === 'application/json',
    'response has name': (r) => r.status === 200 && r.json().name === 'lorem',
  });
}
Checks don’t fail tests by default. Some failure rate is acceptable based on your SLOs. Use thresholds to define pass/fail criteria.

Setting Performance Thresholds

Define SLOs as thresholds to validate reliability goals:
export const options = {
  thresholds: {
    http_req_failed: ['rate<0.01'],    // HTTP errors < 1%
    http_req_duration: ['p(95)<200'],  // 95% of requests < 200ms
    http_req_duration: ['p(99)<500'],  // 99% of requests < 500ms
  },
  scenarios: {
    api_test: {
      executor: 'constant-arrival-rate',
      rate: 50,
      timeUnit: '1s',
      duration: '30s',
      preAllocatedVUs: 50,
    },
  },
};
When thresholds fail, k6 returns a non-zero exit code, which is essential for CI/CD automation.

Advanced Scripting Patterns

Data Parameterization

Use different data for each iteration:
import http from 'k6/http';
import { SharedArray } from 'k6/data';

const users = new SharedArray('users', function () {
  return JSON.parse(open('./users.json')).users;
});

export default function () {
  const user = users[Math.floor(Math.random() * users.length)];
  
  const payload = JSON.stringify({
    name: user.username,
    surname: user.surname,
  });
  
  http.post('https://quickpizza.grafana.com/api/post', payload, {
    headers: { 'Content-Type': 'application/json' },
  });
}

Error Handling

Handle API errors gracefully:
import http from 'k6/http';
import { check } from 'k6';

export default function () {
  const createRes = http.post('https://api.example.com/resources',
    JSON.stringify({ data: 'value' }),
    { headers: { 'Content-Type': 'application/json' } });
  
  check(createRes, { 'created successfully': (r) => r.status === 201 });
  
  // Only proceed if creation succeeded
  if (createRes.status === 201) {
    const resourceId = createRes.json('id');
    http.delete(`https://api.example.com/resources/${resourceId}`);
  }
}

Dynamic URL Grouping

Group similar endpoints to avoid metric explosion:
import http from 'k6/http';

export default function () {
  const userId = Math.floor(Math.random() * 1000);
  
  // Use URL grouping for dynamic IDs
  http.get(`https://api.example.com/users/${userId}`, {
    tags: { name: 'GetUser' },
  });
}

Testing Internal APIs

For APIs in restricted environments:

Local Testing

Run k6 from your private network using the k6 CLI or Kubernetes operator

Cloud Testing

Open firewall for k6 Cloud IPs or use private load zones in your Kubernetes clusters

Integration with API Tools

From Postman Collections

postman-to-k6 collection.json -o k6-script.js
k6 run k6-script.js

From OpenAPI Specifications

openapi-generator-cli generate -i api-spec.json -g k6
k6 run k6-script.js

From HAR Recordings

har-to-k6 archive.har -o k6-script.js
k6 run k6-script.js
While converters help you get started quickly, learning the k6 JavaScript API enables more powerful and maintainable tests.

Beyond HTTP APIs

k6 supports multiple protocols:
import http from 'k6/http';
http.get('https://api.example.com');

Complete Example

Here’s a comprehensive API test with all best practices:
import http from 'k6/http';
import { check, sleep } from 'k6';
import { SharedArray } from 'k6/data';

const users = new SharedArray('users', function () {
  return JSON.parse(open('./users.json')).users;
});

export const options = {
  stages: [
    { duration: '2m', target: 50 },  // Ramp up
    { duration: '5m', target: 50 },  // Steady state
    { duration: '2m', target: 0 },   // Ramp down
  ],
  thresholds: {
    http_req_failed: ['rate<0.01'],
    http_req_duration: ['p(95)<500'],
    'http_req_duration{endpoint:create}': ['p(99)<800'],
  },
};

export default function () {
  const user = users[Math.floor(Math.random() * users.length)];
  const headers = { 'Content-Type': 'application/json' };
  
  // Create resource
  const createRes = http.post(
    'https://api.example.com/resources',
    JSON.stringify({ name: user.username, data: user.data }),
    { headers, tags: { endpoint: 'create' } }
  );
  
  check(createRes, {
    'resource created': (r) => r.status === 201,
    'has resource id': (r) => r.json('id') !== undefined,
  });
  
  sleep(1);
  
  // Get resource if creation succeeded
  if (createRes.status === 201) {
    const resourceId = createRes.json('id');
    const getRes = http.get(
      `https://api.example.com/resources/${resourceId}`,
      { headers, tags: { endpoint: 'get' } }
    );
    
    check(getRes, {
      'resource retrieved': (r) => r.status === 200,
      'correct data': (r) => r.json('name') === user.username,
    });
  }
  
  sleep(1);
}

Best Practices Summary

1

Start simple

Begin with isolated endpoint tests, then expand to integrated and end-to-end flows
2

Validate everything

Use checks to verify response status, headers, and payload correctness
3

Define SLOs

Set thresholds for error rates, latency percentiles, and custom metrics
4

Parameterize data

Use dynamic data to simulate realistic user behavior
5

Handle errors

Implement proper error handling to avoid cascading failures in tests
6

Modularize scripts

Create reusable modules for common scenarios to build a maintainable test suite

Build docs developers (and LLMs) love