# Load testing a web API with k6

> Learn load testing with k6 by testing a web API under smoke, average, and spike traffic, adding checks, thresholds, scenarios, and CI.

Author: [Flavio Copes](https://flaviocopes.com/about/) | Published: 2026-07-24 | Topics: [DevTools](https://flaviocopes.com/tags/devtool/) | Canonical: https://flaviocopes.com/k6-load-testing-tutorial/

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:

```bash
brew install k6
```

Other platforms have packages in the official k6 installation documentation.

Check the installation:

```bash
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:

```text
GET http://localhost:3000/books/1
```

The response is:

```json
{
  "id": "1",
  "title": "The Hobbit"
}
```

## Write the first test

Create `tests/load/smoke.js`:

```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:

```bash
k6 run tests/load/smoke.js
```

A **virtual user**, or VU, repeatedly runs the default function.

Our one VU:

1. sends a request
2. checks the response
3. waits one second
4. 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:

```js
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:

```js
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.

```js
const baseUrl = __ENV.BASE_URL || 'http://localhost:3000'

export default function () {
  const response = http.get(`${baseUrl}/books/1`)
  // ...
}
```

Run against staging:

```bash
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:

1. init code runs once per VU setup
2. `setup()` runs once for the whole test
3. the default function runs repeatedly for each VU
4. `teardown()` runs once at the end

Use `setup()` for shared test preparation:

```js
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`:

```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:

```js
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`:

```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:

```js
http.get(`${baseUrl}/books/${bookId}`, {
  tags: {
    name: 'GET /books/:id',
  },
})
```

Then create a route-specific threshold:

```js
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:

1. list books
2. open one book
3. add it to a reading list

```js
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 requests
- `http_req_duration`: time from sending to receiving the full response
- `http_req_waiting`: time to first byte
- `http_reqs`: total request count
- `iterations`: completed default-function runs
- `dropped_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:

```yaml
- 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:

1. **smoke**: does the script work?
2. **average load**: does expected traffic meet the service objectives?
3. **stress**: where does performance begin to degrade?
4. **spike**: does the system survive a sudden jump?
5. **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.
