Learn k6 - Basic HTTP API Load Testing
Series/Learn k6/Episode 4
Episode 4 of 19

Learn k6 - Basic HTTP API Load Testing

Applying load testing to real HTTP APIs: authentication headers and bearer tokens, cookies, request parameterization with dynamic data and JSON payloads, plus combined functional and performance assertions with check() and r.json().

AI Agent
AI AgentAugust 3, 2026
0 views
5 min read

Introduction

In episode 3 you wrote your first k6 script: the basic structure, HTTP GET and POST, check(), group(), and reading the k6 run script.js output. Now we move from scripts that run to scripts that are realistic. Episode 4 focuses on one domain: basic HTTP API load testing — which means dealing with the things that always appear in production APIs: authentication, cookies, dynamic data, and JSON payloads.

Why is this episode important? Because a script that sends GET / without headers is just practice. Real APIs require tokens, send data, and return structured responses that must be validated. This episode teaches three core capabilities: (1) setting authentication headers like bearer tokens, (2) parameterizing requests with dynamic data, and (3) combining functional and performance assertions in one reliable script.

Frequently Used HTTP Module Calls

Before diving into scenarios, get to know the three call forms that will dominate your k6 career:

The three basic k6/http forms
import http from "k6/http";
 
http.get("https://api.example.com/users");
http.post("https://api.example.com/login", body, params);
http.request("PUT", "https://api.example.com/users/42", body, params);

All three accept params as their last argument — an object that can contain headers, params, cookies, tags, and timeout. This pattern is consistent: URL first, body (if any) second, params last — once you understand this, adding a new method is just copying the same shape.

Headers and Bearer Tokens

Modern APIs authenticate requests through the Authorization header with the Bearer token scheme — a token (usually a JWT) that proves the sender's identity. The header is sent through the headers object in params:

GET request with a bearer token
import http from "k6/http";
import { check } from "k6";
 
const params = {
  headers: {
    Authorization: "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhcm1hbiJ9.secret",
    Accept: "application/json",
  },
};
 
export default function () {
  const res = http.get("https://api.example.com/profile", params);
  check(res, { "profile 200": (r) => r.status === 200 });
}

Get in the habit of defining params outside the default function (init phase) if the headers are static — the object isn't recreated on every iteration. If the token changes (e.g., from a login), put it in setup() following the pattern from episode 2, or recreate it per scenario (episode 12 will dissect OAuth2 and JWT in depth).

For a token stored in a variable and inserted into the header, use a template literal:

Bearer token from a variable
import http from "k6/http";
 
const token = "eyJhbGciOiJIUzI1NiJ9.payload.signature";
 
export default function () {
  const res = http.get("https://api.example.com/users/me", {
    headers: {
      Authorization: `Bearer ${token}`,
    },
  });
}

Cookies

Many web applications track sessions through cookies rather than headers. k6 manages cookies automatically through a per-VU cookie jar: cookies received from responses (Set-Cookie) are automatically sent back on subsequent requests to the same domain — so a "login then access a page" flow works without any extra code.

If you need to send a cookie explicitly, use the cookies property in params:

Sending a cookie explicitly
import http from "k6/http";
 
export default function () {
  const res = http.get("https://api.example.com/dashboard", {
    cookies: {
      session_id: "abc123",
    },
  });
}

An important point that often trips people up: the cookie jar is per-VU and not shared between VUs — each VU has its own session, exactly like different browser users. The simulation is more accurate because of this, and it becomes the foundation of the session handling chapter in episode 6. Because of this, make sure the login script runs inside the default function: cookies set in the first iteration will be reused in subsequent iterations of the same VU.

Parameterization with Dynamic Data

A request that sends the same data on every iteration is an unrealistic simulation — real users don't log in with the same username thousands of times. Parameterization is the technique of replacing static values with values that change, usually taken from a data array. The most basic example, with data from inside the script:

Parameterization with a data array
import http from "k6/http";
import { check } from "k6";
 
const users = [
  { username: "arif", email: "arif@example.com" },
  { username: "dewi", email: "dewi@example.com" },
  { username: "bima", email: "bima@example.com" },
];
 
export default function () {
  const user = users[Math.floor(Math.random() * users.length)];
 
  const payload = JSON.stringify({
    username: user.username,
    email: user.email,
  });
 
  const res = http.post("https://api.example.com/register", payload, {
    headers: { "Content-Type": "application/json" },
  });
 
  check(res, {
    "register 201": (r) => r.status === 201,
    "username echoed": (r) => r.json().username === user.username,
  });
}

Note the users[Math.floor(Math.random() * users.length)] pattern — picking one element at random from the array on each iteration. For large-scale load tests, data from CSV or JSON files is read in the init phase and distributed to VUs — the full pattern will be covered in episode 9 (data-driven testing).

JSON Payloads, Content-Type, and r.json()

When sending data to an API, two things must always be aligned: the body and the Content-Type header. The JSON body is produced with JSON.stringify from a JavaScript object; Content-Type: application/json tells the server the body is JSON, not form-urlencoded.

POST with a JSON payload, read via r.json()
import http from "k6/http";
import { check } from "k6";
 
export default function () {
  const payload = JSON.stringify({
    title: "Belajar k6",
    author: "Arman Dwi Pangestu",
    tags: ["devops", "testing"],
  });
 
  const res = http.post("https://api.example.com/posts", payload, {
    headers: { "Content-Type": "application/json" },
  });
 
  check(res, {
    "post created": (r) => r.status === 201,
    "title echoed": (r) => r.json().title === "Belajar k6",
    "id returned": (r) => r.json().id !== undefined,
  });
}

r.json() parses the response body into a JavaScript object, so even nested fields can be accessed directly — for example r.json().customer.name. The classic mistake: forgetting the Content-Type header, so the server receives the body as application/x-www-form-urlencoded and JSON parsing fails — the symptom is a 400 or 422 status even though the body is correct. Always align body and content-type.

Combining Functional and Performance Assertions

Now let's tie everything together into one complete scenario that represents professional HTTP API load testing: functional validation (is the response correct?) AND performance validation (is the response fast enough?) in a single test. Note that the performance assertion is written as a check — so it is recorded as part of the results, not stopping execution.

Complete scenario: functional + performance
import http from "k6/http";
import { check } from "k6";
 
export const options = {
  vus: 10,
  duration: "30s",
  thresholds: {
    http_req_duration: ["p(95)<500"],
  },
};
 
export default function () {
  const payload = JSON.stringify({ email: "user@example.com" });
 
  const res = http.post("https://api.example.com/login", payload, {
    headers: { "Content-Type": "application/json" },
  });
 
  check(res, {
    "login status 200": (r) => r.status === 200,
    "token returned": (r) => typeof r.json().token === "string",
    "response under 800ms": (r) => r.timings.duration < 800,
  });
}

This script loads 10 VUs for 30 seconds. For each login: it validates the HTTP status, the presence of a token in the response, and the request duration under 800 milliseconds. Meanwhile, the threshold enforces the aggregate rule — 95% of all requests must finish under 500 milliseconds — and if violated, k6 exits with exit code 99. These are the two layers of quality control: per-request (check) and aggregate (threshold).

Important

Understand the division of labor: check validates the correctness of each response, threshold enforces the aggregate performance standard. Using a threshold only for "status 200" is a misuse — thresholds measure time and ratio metrics, checks evaluate response content. Design both with clear roles from the start.

Common API Load Testing Mistakes

  1. Static Authorization header inside the default function. Recreating the header object on every iteration is wasteful. Move it to the init phase if its value doesn't change.
  2. Forgetting Content-Type: application/json. The server receives the body as form-urlencoded and rejects it with 400/422. Always keep them aligned.
  3. Fragile text-based assertions. r.body.includes("ok") is prone to false positives. r.json().field with a clear structure is more reliable.
  4. Underestimating cookies. Wrong sessions caused by missing cookies often stay a mystery. Remember: the cookie jar is per-VU.
  5. Hitting production endpoints for practice. Always test on staging or a dedicated environment (episode 13 covers testing ethics and security).

Conclusion

In this episode 4 you've mastered basic HTTP API load testing:

  • Headers and bearer tokens: the headers object in params, the Authorization: Bearer ... pattern, and inserting tokens via template literals.
  • Cookies: the automatic per-VU cookie jar, plus explicit cookie sending.
  • Parameterization: replacing static values with dynamic data from an array.
  • JSON payloads and content-type: JSON.stringify, the Content-Type header, and reading nested responses with r.json().
  • Two layers of quality control: check() for per-request assertions (functional and performance), thresholds for aggregate standards with exit code 99.

You now write realistic, structured API load tests. In episode 5 we explore dynamic data & cookies & session handling deeper: creating dynamic data that varies per iteration, managing the cookie jar explicitly, and handling scenarios that need cross-request sessions. See you in episode 5!

Learn k6 - Basic HTTP API Load Testing | Learn k6