Load testing a web API with k6
By Flavio Copes
Learn load testing with k6 by testing a web API under smoke, average, and spike traffic, adding checks, thresholds, scenarios, and CI.
A response can be fast when one person uses an API and slow when five hundred people use it.
Functional tests answer:
Does this endpoint return the right result?
A load test answers:
Does it still return the right result, quickly enough, under expected traffic?
In this tutorial we’ll use k6 to test a small HTTP API.
We’ll write:
- a smoke test
- an average-load test
- a spike test
- checks for correctness
- thresholds that fail the test
Then we’ll run the smoke test in CI.
Install k6
On macOS:
brew install k6
Other platforms have packages in the official k6 installation documentation.
Check the installation:
k6 version
k6 tests are written in JavaScript or TypeScript, but k6 is not Node.js. It has its own runtime and modules.
Do not expect every npm package or Node built-in module to work inside a k6 script.
Choose a safe test target
Never load test a service you do not own or have permission to test.
Start locally or in an isolated staging environment. A load test can create real traffic, data, emails, charges, and alerts.
We’ll assume an API exposes:
GET http://localhost:3000/books/1
The response is:
{
"id": "1",
"title": "The Hobbit"
}
Write the first test
Create tests/load/smoke.js:
import http from 'k6/http'
import { check, sleep } from 'k6'
export const options = {
vus: 1,
duration: '10s',
}
export default function () {
const response = http.get('http://localhost:3000/books/1')
check(response, {
'status is 200': (result) => result.status === 200,
'body contains a title': (result) => {
return result.json('title') !== undefined
},
})
sleep(1)
}
Run it:
k6 run tests/load/smoke.js
A virtual user, or VU, repeatedly runs the default function.
Our one VU:
- sends a request
- checks the response
- waits one second
- repeats for ten seconds
This is a smoke test. It verifies that the script and environment work with almost no load.
Checks do not fail the test by themselves
check() records whether an assertion passed, but a failed check does not automatically make the k6 process exit with an error.
Add a threshold:
export const options = {
vus: 1,
duration: '10s',
thresholds: {
checks: ['rate==1'],
},
}
Now all checks must pass.
Thresholds turn performance expectations into pass or fail conditions.
Add performance thresholds
Suppose the API requirements say:
- fewer than 1% of requests may fail
- 95% of requests must finish within 300 ms
- 99% must finish within 800 ms
Express them directly:
export const options = {
vus: 1,
duration: '10s',
thresholds: {
checks: ['rate==1'],
http_req_failed: ['rate<0.01'],
http_req_duration: ['p(95)<300', 'p(99)<800'],
},
}
p(95) is the 95th percentile. It means 95% of measured request durations were below that value.
An average can hide a bad experience. Nine fast requests and one very slow request may still have a respectable average. Percentiles show the long tail more clearly.
Use an environment variable for the URL
Hardcoding localhost makes the script hard to reuse.
const baseUrl = __ENV.BASE_URL || 'http://localhost:3000'
export default function () {
const response = http.get(`${baseUrl}/books/1`)
// ...
}
Run against staging:
k6 run \
-e BASE_URL=https://staging.example.com \
tests/load/smoke.js
Keep credentials out of the script and repository. Pass them through the environment or your CI secret system.
Understand the test lifecycle
A k6 test has four phases:
- init code runs once per VU setup
setup()runs once for the whole test- the default function runs repeatedly for each VU
teardown()runs once at the end
Use setup() for shared test preparation:
export function setup() {
const response = http.post(
`${baseUrl}/test-session`,
JSON.stringify({
role: 'load-tester',
}),
{
headers: {
'content-type': 'application/json',
},
}
)
return {
token: response.json('token'),
}
}
export default function (data) {
http.get(`${baseUrl}/books/1`, {
headers: {
authorization: `Bearer ${data.token}`,
},
})
}
Do not put an expensive login inside every iteration unless login is the behavior you want to load test.
At the same time, one shared account can create unrealistic locking or cache behavior. For a serious test, prepare a pool of representative test users.
Test average traffic with scenarios
vus and duration are good for a first test. Scenarios give us control over how traffic changes.
Create tests/load/average.js:
import http from 'k6/http'
import { check, sleep } from 'k6'
const baseUrl = __ENV.BASE_URL || 'http://localhost:3000'
export const options = {
scenarios: {
average_load: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '30s', target: 20 },
{ duration: '2m', target: 20 },
{ duration: '30s', target: 0 },
],
gracefulRampDown: '10s',
},
},
thresholds: {
checks: ['rate>0.99'],
http_req_failed: ['rate<0.01'],
http_req_duration: ['p(95)<300'],
},
}
export default function () {
const response = http.get(`${baseUrl}/books/1`, {
tags: {
name: 'GET /books/:id',
},
})
check(response, {
'status is 200': (result) => result.status === 200,
})
sleep(Math.random() * 2 + 1)
}
This test ramps to 20 concurrent virtual users, holds that level for two minutes, then ramps down.
The random sleep avoids making every VU behave in perfect lockstep.
Closed and open workload models
The ramping-vus executor uses a closed model. Each VU waits for its request to finish before starting its next iteration.
If the server slows down, the generated request rate falls.
That may match a small group of users, but it can hide overload for a public endpoint where new requests arrive independently.
An open model starts iterations at a configured rate:
export const options = {
scenarios: {
api_traffic: {
executor: 'ramping-arrival-rate',
startRate: 10,
timeUnit: '1s',
preAllocatedVUs: 20,
maxVUs: 100,
stages: [
{ duration: '1m', target: 50 },
{ duration: '3m', target: 50 },
{ duration: '1m', target: 0 },
],
},
},
}
This targets iterations per second, even when responses slow down, up to the available VUs.
Choose a workload model that represents how traffic reaches the real system.
Create a spike test
A spike test checks what happens when traffic rises suddenly.
Create tests/load/spike.js:
import http from 'k6/http'
import { sleep } from 'k6'
const baseUrl = __ENV.BASE_URL || 'http://localhost:3000'
export const options = {
scenarios: {
spike: {
executor: 'ramping-vus',
startVUs: 5,
stages: [
{ duration: '30s', target: 5 },
{ duration: '10s', target: 100 },
{ duration: '1m', target: 100 },
{ duration: '10s', target: 5 },
{ duration: '30s', target: 5 },
],
},
},
thresholds: {
http_req_failed: ['rate<0.05'],
http_req_duration: ['p(95)<1000'],
},
}
export default function () {
http.get(`${baseUrl}/books/1`)
sleep(1)
}
The thresholds are looser than the average-load test. Whether that is acceptable is a product decision, not a k6 decision.
After the spike, check whether the service recovers. A system that remains slow after traffic returns to normal may have exhausted a connection pool, filled a queue, or triggered expensive retries.
Group requests by route, not raw URL
If every book ID becomes a separate metric URL, the result contains too many time series.
Tag dynamic routes with a stable name:
http.get(`${baseUrl}/books/${bookId}`, {
tags: {
name: 'GET /books/:id',
},
})
Then create a route-specific threshold:
thresholds: {
'http_req_duration{name:GET /books/:id}': ['p(95)<300'],
}
This is the same cardinality problem we meet in production metrics. Use bounded labels.
Test a realistic flow
One request rarely represents a person.
A browsing flow might:
- list books
- open one book
- add it to a reading list
export default function () {
const list = http.get(`${baseUrl}/books`)
check(list, {
'list loaded': (result) => result.status === 200,
})
sleep(2)
const book = http.get(`${baseUrl}/books/1`)
check(book, {
'book loaded': (result) => result.status === 200,
})
sleep(3)
}
Keep test data isolated. A write-heavy test should clean up after itself or use data that can be discarded with the entire test environment.
Read the results
k6 prints many built-in metrics.
Start with:
http_req_failed: failed HTTP requestshttp_req_duration: time from sending to receiving the full responsehttp_req_waiting: time to first bytehttp_reqs: total request countiterations: completed default-function runsdropped_iterations: work k6 could not start at the requested rate
Correlate those numbers with server metrics:
- CPU
- memory
- database query time
- connection pool usage
- queue depth
- cache hit rate
- error logs
A load test tells you when behavior changed. Server telemetry tells you why.
Run the smoke test in CI
Do not run a large load test on every pull request. It is slow, noisy, and can conflict with other builds.
A short smoke test is useful.
With an API already started in the workflow:
- name: Run API
run: npm start &
- name: Wait for API
run: npx wait-on http://localhost:3000/health
- name: Run k6 smoke test
run: |
docker run --rm --network host \
-v "$PWD:/work" \
-w /work \
grafana/k6 run tests/load/smoke.js
Networking differs between CI systems and Docker environments. If --network host is unavailable, run k6 as an installed binary or put both services on the same Docker network.
Schedule longer tests against an isolated environment, or run them before a major release.
Avoid misleading tests
The easiest load test to write is also the easiest to misread.
Watch for these mistakes:
- generating traffic from a laptop with a slow network
- testing production without coordination
- skipping a warm-up period
- using one cached URL for every request
- using unrealistic think times
- starting with enormous traffic before finding the normal limit
- declaring success from averages alone
- ignoring errors because latency stayed low
- changing the test and the application at the same time
First establish a repeatable baseline. Then change one thing and compare.
A practical testing sequence
Use several small tests with clear purposes:
- smoke: does the script work?
- average load: does expected traffic meet the service objectives?
- stress: where does performance begin to degrade?
- spike: does the system survive a sudden jump?
- soak: does it remain healthy for hours?
You do not need to run all five on day one.
Start with expected traffic and explicit thresholds. A performance number without an agreed requirement is only an observation.
We now have executable answers to important questions: how the API behaves under normal traffic, what happens during a spike, and whether a release still meets its limits.
Related posts about devtool: