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.
| 1 | placeholder-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 |
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:
| 1 | export const BASE_URL = 'https://jsonplaceholder.typicode.com' |
| 2 | export const USERS_ENDPOINT = 'users' |
| 3 | export const TODOS_ENDPOINT = 'todos' |
| 4 | export const POSTS_ENDPOINT = 'posts' |
| 5 | |
| 6 | export const USER_ID = 1 |
| 7 | export const USER_ID_NOT_FOUND = 123123 |
| 8 | export const USERNAME = 'Antonette' |
| 9 | |
| 10 | export const TODO_ID = 5 |
| 11 | export const TODO_ID_NOT_FOUND = 123123 |
| 12 | |
| 13 | export const POST_ID = 5 |
| 14 | export const POST_ID_NOT_FOUND = 123123 |
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
| 1 | import { check } from "k6"; |
| 2 | import { BASE_URL, USERS_ENDPOINT } from "../../utils/config.js"; |
| 3 | |
| 4 | export 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 | |
| 17 | export 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 | } |
02.
The Load Stages
| 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 | ], |
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.
| 1 | thresholds: { |
| 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 | }, |
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
| 1 | check(res, { |
| 2 | "Status 200": (r) => r.status === 200, |
| 3 | "Response time < 5000ms": (r) => r.timings.duration < 5000, |
| 4 | }); |
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:
| 1 | import { check, group } from "k6"; |
| 2 | |
| 3 | export 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 | } |
And in the thresholds, each group gets its own performance contract:
| 1 | thresholds: { |
| 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 | }, |
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
| 1 | k6 run tests/posts/getPosts.js |
| 2 | |
| 3 | #Run with a specific number of VUs (override options) |
| 4 | k6 run --vus 100 --duration 30s tests/posts/getPosts.js |
| 5 | |
| 6 | #Run against a different environment |
| 7 | BASE_URL=https://staging.api.com k6 run tests/posts/getPosts.js |
What the Output Looks Like
| 1 | ✓ Status 200 |
| 2 | ✓ Response time < 5000ms |
| 3 | checks.........................: 98.45% ✓ 49225 ✗ 771 |
| 4 | data_received..................: 45 MB 750 kB/s |
| 5 | data_sent......................: 4.2 MB 70 kB/s |
| 6 | http_req_duration..............: avg=312ms min=89ms med=278ms max=4821ms p(90)=521ms p(95)=743ms |
| 7 | http_req_failed................: 0.12% ✓ 60 ✗ 49940 |
| 8 | http_reqs......................: 50000 833/s |
Every metric tells a story:
http_reqs: 50000 at 833/s — the suite sent 50,000 total requests at 833 per secondp(95)=743ms — 95% of requests responded in under 743mshttp_req_failed: 0.12% — only 0.12% of requests failed — well within the 1% thresholdchecks: 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
| Resource | Operation | Scenario |
|---|---|---|
| Users | GET all | Load under 500 VUs |
| Users | GET by ID | Happy path + not found |
| Users | POST | Create under load |
| Users | PUT | Update + missing ID + not found |
| Users | PATCH | Partial update + error scenarios |
| Users | DELETE | Delete + missing ID + not found |
| Users | GET with filter | Search by username |
| Posts | GET all | Load under 500 VUs |
| Posts | GET by ID | Happy path + not found |
| Posts | POST | Create under load |
| Posts | PUT | Update + missing ID + not found |
| Posts | PATCH | Partial update + error scenarios |
| Posts | DELETE | Up to 2,000 VUs |
| Posts | GET with filter | Filter by user ID |
| Posts | GET nested | Posts inside a user |
| Todos | GET all | Load under 500 VUs |
| Todos | GET by ID | Happy path + not found |
| Todos | POST | Create under load |
| Todos | PUT | Update + error scenarios |
| Todos | PATCH | Partial update + error scenarios |
| Todos | DELETE | Delete + error scenarios |
| Todos | GET with filter | Filter by user ID |
| Todos | GET nested | Todos 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
for scripting and running load tests against REST APIs
Jenkins
runs the load tests automatically as part of the CI/CD pipeline