February 23, 2026 · 8 min read · loadtest.qa

How to Load Test a REST API: Step-by-Step with k6

Hands-on tutorial for load testing REST APIs with k6 - from first script to Grafana dashboards, with authentication, data files, and thresholds.

How to Load Test a REST API: Step-by-Step with k6

Load testing a REST API with k6 is one of the most practical skills a backend engineer can have. This tutorial walks through the complete process from installing k6 to running a realistic multi-user scenario against a REST API, with authentication, data files, thresholds, and result export to Grafana.

Prerequisites

  • k6 installed (macOS: brew install k6, Linux: see k6.io/docs/get-started/installation)
  • A REST API to test (we use example.com patterns throughout)
  • curl or httpie for manual endpoint exploration

Verify installation:

k6 version
# k6 v0.52.0 (go1.22.3, linux/amd64)

Your First k6 Script

Start simple. This script sends GET requests to an endpoint and checks the response:

// first-test.js
import http from 'k6/http';
import { check } from 'k6';

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

export default function () {
  const res = http.get('https://api.example.com/users');

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

Run it:

k6 run first-test.js

k6 outputs a summary after the test completes:

          /\      |‾‾| /‾‾/   /‾‾/
     /\  /  \     |  |/  /   /  /
    /  \/    \    |     (   /   ‾‾\
   /          \   |  |\  \ |  (‾)  |
  / __________ \  |__| \__\ \_____/ .io

  execution: local
     script: first-test.js
     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

     checks.........................: 100.00% ✓ 2847  ✗ 0
     data_received..................: 4.2 MB  140 kB/s
     data_sent......................: 341 kB  11 kB/s
     http_req_blocked...............: avg=1.2ms min=100µs med=500µs max=45ms
     http_req_duration..............: avg=52ms  min=18ms  med=45ms  max=312ms
         { expected_response:true }..: avg=52ms  min=18ms  med=45ms  max=312ms
     http_req_failed................: 0.00%  ✓ 0    ✗ 2847
     http_req_receiving.............: avg=3ms   min=1ms   med=2ms   max=45ms
     http_req_sending...............: avg=1ms   min=500µs med=1ms   max=8ms
     http_req_waiting...............: avg=48ms  min=15ms  med=42ms  max=300ms
     http_reqs......................: 2847   94.89/s
     iteration_duration.............: avg=105ms min=72ms  med=95ms  max=370ms
     vus............................: 10      min=10      max=10
     vus_max........................: 10      min=10      max=10

Reading the output: http_req_duration shows p50 (med), average (avg), and max values. The checks line shows how many checks passed and failed.

Realistic Traffic Simulation with Stages

Real traffic does not start at full load. Use stages to simulate ramp-up, sustained load, and ramp-down:

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

export const options = {
  stages: [
    { duration: '2m', target: 25 },   // Ramp up to 25 users over 2 minutes
    { duration: '5m', target: 25 },   // Hold at 25 users for 5 minutes
    { duration: '2m', target: 75 },   // Ramp up to 75 users
    { duration: '5m', target: 75 },   // Hold at 75 users
    { duration: '2m', target: 150 },  // Ramp up to 150 users
    { duration: '5m', target: 150 },  // Hold at 150 users
    { duration: '3m', target: 0 },    // Ramp down to 0
  ],
};

export default function () {
  const res = http.get('https://api.example.com/products');

  check(res, {
    'status is 200': (r) => r.status === 200,
  });

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

The sleep() call is essential. Without think time, k6 fires requests as fast as possible, generating artificial load patterns. Real users pause between actions.

Authenticated Endpoints

Most APIs require authentication. Here is how to handle JWT bearer tokens:

// authenticated-test.js
import http from 'k6/http';
import { check, fail } from 'k6';

export const options = {
  vus: 50,
  duration: '5m',
};

const BASE_URL = __ENV.BASE_URL || 'https://api.example.com';

// Runs once per VU at startup - ideal for authentication
export function setup() {
  // Note: setup() runs once total (not per VU) - use init code for per-VU auth
  return {};
}

// Per-VU initialization - runs once per VU before the default function loop
// In k6, auth in the default function is the standard pattern for per-VU tokens
export default function () {
  // Step 1: Authenticate
  const loginPayload = JSON.stringify({
    email: `testuser+${__VU}@example.com`,  // Unique per VU
    password: 'LoadTestPassword123!',
  });

  const loginRes = http.post(`${BASE_URL}/auth/token`, loginPayload, {
    headers: { 'Content-Type': 'application/json' },
  });

  if (!check(loginRes, { 'login succeeded': (r) => r.status === 200 })) {
    fail('Authentication failed - stopping VU');
  }

  const token = loginRes.json('access_token');
  const headers = {
    'Authorization': `Bearer ${token}`,
    'Content-Type': 'application/json',
  };

  // Step 2: Use authenticated endpoint
  const profileRes = http.get(`${BASE_URL}/users/me`, { headers });
  check(profileRes, {
    'profile status 200': (r) => r.status === 200,
    'profile has id': (r) => r.json('id') !== undefined,
  });

  // Step 3: Refresh token if needed (simulate long sessions)
  // For long-running tests, implement token refresh logic here
}

Performance tip: If all VUs authenticate before the test starts, the authentication endpoint gets a burst of requests at t=0. Stagger authentication by adding a sleep(__VU * 0.1) before the first auth call.

POST, PUT, DELETE with Data Files

Using realistic test data prevents caching from masking real performance characteristics. Store test data in JSON files:

Create test-data/products.json:

[
  { "name": "Widget Pro", "price": 29.99, "sku": "WGT-001", "category": "tools" },
  { "name": "Gadget Plus", "price": 49.99, "sku": "GDG-002", "category": "electronics" },
  { "name": "Doohickey Basic", "price": 9.99, "sku": "DHK-003", "category": "accessories" }
]

Load and use in your test script:

// data-driven-test.js
import http from 'k6/http';
import { check } from 'k6';
import { SharedArray } from 'k6/data';

// SharedArray loads data once and shares across all VUs (memory efficient)
const products = new SharedArray('products', function () {
  return JSON.parse(open('./test-data/products.json'));
});

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

export const options = {
  vus: 50,
  duration: '10m',
};

const BASE_URL = __ENV.BASE_URL || 'https://api.example.com';

export default function () {
  // Pick a random user for this iteration
  const user = users[__VU % users.length];

  // Authenticate
  const loginRes = http.post(`${BASE_URL}/auth/token`,
    JSON.stringify({ email: user.email, password: user.password }),
    { headers: { 'Content-Type': 'application/json' } }
  );
  const token = loginRes.json('access_token');
  const headers = { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' };

  // CREATE - POST new product
  const product = products[Math.floor(Math.random() * products.length)];
  const createRes = http.post(
    `${BASE_URL}/products`,
    JSON.stringify({
      ...product,
      sku: `${product.sku}-${Date.now()}`,  // Unique SKU per request
    }),
    { headers }
  );

  const created = check(createRes, {
    'create product status 201': (r) => r.status === 201,
    'create returns product id': (r) => r.json('id') !== undefined,
  });

  if (!created) return;

  const productId = createRes.json('id');

  // READ - GET the created product
  const getRes = http.get(`${BASE_URL}/products/${productId}`, { headers });
  check(getRes, { 'get product status 200': (r) => r.status === 200 });

  // UPDATE - PUT updated product
  const updateRes = http.put(
    `${BASE_URL}/products/${productId}`,
    JSON.stringify({ ...product, price: product.price * 1.1 }),
    { headers }
  );
  check(updateRes, { 'update product status 200': (r) => r.status === 200 });

  // DELETE - delete the test product
  const deleteRes = http.del(`${BASE_URL}/products/${productId}`, null, { headers });
  check(deleteRes, { 'delete product status 204': (r) => r.status === 204 });
}

Thresholds as Quality Gates

Thresholds define pass/fail criteria for your test. k6 exits with a non-zero code if any threshold is violated, making it straightforward to fail CI/CD pipelines:

export const options = {
  stages: [
    { duration: '2m', target: 100 },
    { duration: '5m', target: 100 },
    { duration: '2m', target: 0 },
  ],
  thresholds: {
    // Overall request duration
    'http_req_duration': [
      'p(95)<500',    // 95% of requests complete in < 500ms
      'p(99)<2000',   // 99% of requests complete in < 2000ms
    ],

    // Error rate
    'http_req_failed': ['rate<0.01'],  // Less than 1% errors

    // Specific endpoints (using tags)
    'http_req_duration{name:login}': ['p(95)<1000'],
    'http_req_duration{name:checkout}': ['p(95)<3000'],

    // Custom business metrics
    'checkout_success_rate': ['rate>0.995'],

    // Checks pass rate
    'checks': ['rate>0.99'],
  },
};

Tag specific requests for per-endpoint thresholds:

const loginRes = http.post(
  `${BASE_URL}/auth/token`,
  JSON.stringify({ email, password }),
  {
    headers: { 'Content-Type': 'application/json' },
    tags: { name: 'login' },  // This request appears as 'login' in thresholds
  }
);

Reading Results

After a test run, k6 prints a results summary. Key metrics to review:

http_req_duration..............: avg=95ms  min=18ms  med=76ms  p(90)=165ms p(95)=225ms p(99)=612ms max=3241ms

What to look for:

  • Large gap between p95 and p99: indicates outlier requests (slow queries, cache misses, timeouts)
  • Large gap between p50 and p95: indicates that load is causing more than average degradation
  • Max significantly higher than p99: isolated extreme outliers (timeouts, retries)

Check which checks failed:

✓ status is 200 (2980/3000)
✗ response time < 500ms (2820/3000) - 6% failures

This tells you 6% of requests exceeded your 500ms threshold - a significant finding that warrants investigation.

Exporting Results to Grafana

For better result visualization, stream k6 metrics to a Grafana stack in real time.

Set up InfluxDB and Grafana locally with Docker:

docker run -d \
  --name influxdb \
  -p 8086:8086 \
  -e INFLUXDB_DB=k6 \
  influxdb:1.8

docker run -d \
  --name grafana \
  -p 3000:3000 \
  --link influxdb:influxdb \
  grafana/grafana

Run k6 with InfluxDB output:

k6 run \
  --out influxdb=http://localhost:8086/k6 \
  api-load-test.js

Import the official k6 Grafana dashboard (ID: 2587) in Grafana. It provides real-time visualization of VUs, request rate, response time percentiles, and error rate during the test.

Alternatively, use k6 Cloud (free tier available) for hosted result storage and comparison between test runs:

k6 cloud api-load-test.js

Avoiding Common Mistakes

Do not forget sleep(). Scripts without think time generate artificial load. Real users do not send 100 requests per second from a single session.

Use unique data per VU. Using the same user ID or product ID across all VUs means database queries hit the same cache entries repeatedly. Use __VU and __ITER to vary data: email: testuser+${__VU}@example.com.

Set timeouts explicitly. k6’s default timeout is 60 seconds. For APIs that should respond in under 2 seconds, set a stricter timeout to catch hangs quickly:

const res = http.get(url, { timeout: '10s' });

Test your test environment first. Run with 1 VU before scaling up. Verify you are testing what you think you are testing (correct endpoints, correct auth, correct data).

Parse and use response data. If the API returns pagination cursors, use them. If it returns IDs needed for subsequent requests, extract and use them. Realistic request sequencing matters.

Our API load testing service builds production-grade test suites for your specific API, including data setup, scenario design, and CI/CD integration - delivered within one sprint.

Know Your Scaling Ceiling

Book a free 30-minute capacity scope call with our load testing engineers. We review your architecture, traffic expectations, and upcoming scaling events — and scope the load test that will give you the data you need.

Talk to an Expert