API Load Testing with k6

Your API works perfectly in development. One user, one request, instant response. But what happens when 500 users hit the same endpoint at the same time? What about 2,000? Does your server slow down? Does it crash? Does it return errors? These are questions that only load testing can answer — and K6 is one of the best tools to do it.

Project structure

The project is split into two clear layers — test scripts and a shared config file. The config file holds all the base URLs, endpoint paths, and test data constants. The test scripts import from that config, so if an endpoint changes, there's only one place to update it.

the implementation

Project Structure

The suite is organized around the three resources the API exposes — users, posts, and todos. Every HTTP operation for each resource gets its own file.

javascript
1placeholder-api-k6/
2 utils/
3 config.js ← base URL, endpoints, shared test constants
4 tests/
5 users/
6 getUsers.js ← GET all users
7 getUser.js ← GET single user
8 addUser.js ← POST new user
9 updateUser.js ← PUT update user
10 patchUser.js ← PATCH partial update
11 deleteUser.js ← DELETE user
12 searchUserByName.js ← GET with query param
13 posts/
14 getPosts.js ← GET all posts
15 getPost.js ← GET single post
16 addPost.js ← POST new post
17 updatePost.js ← PUT update post (with groups)
18 patchPost.js ← PATCH partial update (with groups)
19 deletePost.js ← DELETE post (with groups)
20 filterPostByUser.js ← GET with query param
21 postInUser.js ← GET nested resource
22 todos/
23 getTodos.js ← GET all todos
24 getTodo.js ← GET single todo
25 addTodo.js ← POST new todo
26 updateTodo.js ← PUT update todo
27 patchTodo.js ← PATCH partial update
28 deleteTodo.js ← DELETE todo
29 filterTodoByUser.js ← GET with query param
30 todoInUser.js ← GET nested resource
30 lines

One operation, one file. This makes it easy to run a specific test in isolation, compare results between runs, and add new endpoints without touching existing files.

The Config File — One Source of Truth

All shared values live in utils/config.js:

javascript
1export const BASE_URL = 'https://jsonplaceholder.typicode.com'
2export const USERS_ENDPOINT = 'users'
3export const TODOS_ENDPOINT = 'todos'
4export const POSTS_ENDPOINT = 'posts'
5
6export const USER_ID = 1
7export const USER_ID_NOT_FOUND = 123123
8export const USERNAME = 'Antonette'
9
10export const TODO_ID = 5
11export const TODO_ID_NOT_FOUND = 123123
12
13export const POST_ID = 5
14export const POST_ID_NOT_FOUND = 123123
14 lines

Every test imports from this file. When the base URL changes — say, from staging to production — you update one line and every test picks it up automatically.

USER_ID_NOT_FOUND = 123123 is intentional. It's an ID that doesn't exist in the system, used specifically to test how the API behaves when you request something that isn't there

Anatomy of a K6 Test

Every test file follows the same structure: options (how to run the test) and default function (what to do on each iteration).

01.

A simple GET test

javascript
1import { check } from "k6";
2import { BASE_URL, USERS_ENDPOINT } from "../../utils/config.js";
3
4export const options = {
5 stages: [
6 { duration: "10s", target: 10 },
7 { duration: "30s", target: 500 },
8 { duration: "1m", target: 10 },
9 ],
10 thresholds: {
11 http_req_duration: ["p(95)<5000"],
12 http_req_failed: ["rate<0.01"],
13 checks: ["rate>0.75"],
14 },
15};
16
17export default function () {
18 let res = http.get(`${BASE_URL}/${USERS_ENDPOINT}`);
19 check(res, {
20 "Status 200": (r) => r.status === 200,
21 "Response time < 5000ms": (r) => r.timings.duration < 5000,
22 });
23}
23 lines

02.

The Load Stages

javascript
1{ duration: "10s", target: 10 }, // ramp up to 10 virtual users
2 { duration: "30s", target: 500 }, // ramp up to 500 virtual users
3 { duration: "1m", target: 10 }, // ramp back down to 10
4],
4 lines

This is a ramp-up load pattern. It simulates real-world traffic more accurately than suddenly hitting an API with 500 users at once.

Stage 1 (10s → 10 VUs): warmup. Gets the server out of idle state. Stage 2 (30s → 500 VUs): the stress phase. This is where you find breaking points. Stage 3 (1m → 10 VUs): cool-down. Checks if the server recovers gracefully after heavy load. For the delete test, the target goes all the way to 2,000 virtual users — because delete operations are typically lighter on the server than reads with large payloads, so they can handle more concurrency.

03.

The Thresholds — Pass or Fail

Thresholds are what turn a load test from "interesting data" into a pass/fail result. If a threshold is breached, K6 exits with a non-zero code — which means it fails your CI/CD pipeline.

javascript
1thresholds: {
2 http_req_duration: ["p(95)<5000"], // 95% of requests must finish under 5 seconds
3 http_req_failed: ["rate<0.01"], // less than 1% of requests can fail
4 checks: ["rate>0.75"], // at least 75% of checks must pass
5},
5 lines

p(95)<5000 means the 95th percentile response time must be under 5,000ms. This is the industry-standard way to measure API performance — you ignore the worst 5% of outliers and focus on what the vast majority of users experience.

rate<0.01 on http_req_failed means the API is allowed to fail on less than 1% of requests. Anything above that and the test fails.

rate>0.75 on checks means at least 75% of your custom assertions (status codes, response times) must pass.

04.

The Checks — What You're Asserting

javascript
1check(res, {
2 "Status 200": (r) => r.status === 200,
3 "Response time < 5000ms": (r) => r.timings.duration < 5000,
4});
4 lines

Checks are assertions that run on every single response. Every virtual user, on every iteration, verifies that the status code is correct and the response came back within the time limit. K6 aggregates these results across all VUs and all iterations, giving you a percentage pass rate.

Groups — Testing Multiple Scenarios in One File

For write operations (PUT, PATCH, DELETE), tests cover three scenarios in one file: the happy path, missing ID, and ID not found. The group() function organizes these scenarios and lets you set per-group thresholds:

javascript
1import { check, group } from "k6";
2
3export default function () {
4 group("Update post", function () {
5 let res = http.put(`${BASE_URL}/${POSTS_ENDPOINT}/${POST_ID}`, ...);
6 check(res, {
7 "Status 200": (r) => r.status === 200,
8 "Response time < 2000ms": (r) => r.timings.duration < 2000,
9 });
10 });
11
12 group("Update post - Missing ID", function () {
13 let res = http.put(`${BASE_URL}/${POSTS_ENDPOINT}`, ...);
14 check(res, {
15 "Status 404": (r) => r.status === 404,
16 "Response time < 2000ms": (r) => r.timings.duration < 2000,
17 });
18 });
19
20 group("Update post - ID not found", function () {
21 let res = http.put(`${BASE_URL}/${POSTS_ENDPOINT}/${POST_ID_NOT_FOUND}`, ...);
22 check(res, {
23 "Status 500": (r) => r.status === 500,
24 "Response time < 2000ms": (r) => r.timings.duration < 2000,
25 });
26 });
27}
27 lines

And in the thresholds, each group gets its own performance contract:

javascript
1thresholds: {
2 "http_req_duration{group:::Update post}": ["p(95)<1000"],
3 "http_req_failed{group:::Update post}": ["rate<0.01"],
4 "http_req_failed{group:::Update post - Missing ID}": ["rate>0.95"],
5 "http_req_failed{group:::Update post - ID not found}": ["rate>0.95"],
6},
6 lines

Notice the difference: the happy path expects rate<0.01 (almost no failures), while error scenarios expect rate>0.95 (almost all should "fail" with 404 or 500). This is intentional — it verifies that the API correctly rejects invalid requests under load, not just under normal conditions.

Running the Tests

javascript
1k6 run tests/posts/getPosts.js
2
3#Run with a specific number of VUs (override options)
4k6 run --vus 100 --duration 30s tests/posts/getPosts.js
5
6#Run against a different environment
7BASE_URL=https://staging.api.com k6 run tests/posts/getPosts.js
7 lines

What the Output Looks Like

javascript
1✓ Status 200
2✓ Response time < 5000ms
3checks.........................: 98.45% ✓ 49225 ✗ 771
4data_received..................: 45 MB 750 kB/s
5data_sent......................: 4.2 MB 70 kB/s
6http_req_duration..............: avg=312ms min=89ms med=278ms max=4821ms p(90)=521ms p(95)=743ms
7http_req_failed................: 0.12% ✓ 60 ✗ 49940
8http_reqs......................: 50000 833/s
8 lines

Every metric tells a story:

  • http_reqs: 50000 at 833/s — the suite sent 50,000 total requests at 833 per second
  • p(95)=743ms — 95% of requests responded in under 743ms
  • http_req_failed: 0.12% — only 0.12% of requests failed — well within the 1% threshold
  • checks: 98.45% — almost all assertions passed If any threshold is breached, the line turns red and K6 exits with a failure code.

What This Suite Covers

ResourceOperationScenario
UsersGET allLoad under 500 VUs
UsersGET by IDHappy path + not found
UsersPOSTCreate under load
UsersPUTUpdate + missing ID + not found
UsersPATCHPartial update + error scenarios
UsersDELETEDelete + missing ID + not found
UsersGET with filterSearch by username
PostsGET allLoad under 500 VUs
PostsGET by IDHappy path + not found
PostsPOSTCreate under load
PostsPUTUpdate + missing ID + not found
PostsPATCHPartial update + error scenarios
PostsDELETEUp to 2,000 VUs
PostsGET with filterFilter by user ID
PostsGET nestedPosts inside a user
TodosGET allLoad under 500 VUs
TodosGET by IDHappy path + not found
TodosPOSTCreate under load
TodosPUTUpdate + error scenarios
TodosPATCHPartial update + error scenarios
TodosDELETEDelete + error scenarios
TodosGET with filterFilter by user ID
TodosGET nestedTodos inside a user

A K6 load testing suite built with clear stages, strict thresholds, and grouped scenarios gives you a precise answer to the question every engineering team needs to ask before launch: can this API handle real traffic? With one file per operation, a shared config, and thresholds that fail your pipeline on breach, load testing stops being a last-minute fire drill and becomes a standard part of how you ship.

the testing stack

Every tool chosen with purpose — from feature to assertion.

K6

K6

for scripting and running load tests against REST APIs

Jenkins

Jenkins

runs the load tests automatically as part of the CI/CD pipeline

Available for work

|

I'd love to hear about it — let's see if we're a good fit.

cahyaputraugira95@gmail.com