Skip to content
Kavindu's Blog
Go back

Test Your API Before Traffic Does It For You: A k6 Beginner's Guide

Kavindu Manahara

Test Your API Before Traffic Does It For You: A k6 Beginner's Guide

We had a promotional email go out to about eight thousand people at once. Every one of them clicked the same link within about ten minutes of each other, straight into the same login endpoint. The API had been running fine for months under normal traffic, a few requests a second at most, and nobody had ever asked what it did under a real spike because there had never been one.

It fell over. Not completely, requests were still going through, but response times climbed past ten seconds and a chunk of them started timing out. Everything I’d tested up to that point was correctness: does this endpoint return the right data for the right input. Nobody had tested what happens when three hundred people ask for it at the same time.

Load testing is a different question than “does it work”

Load testing means throwing real concurrent traffic at your API to see whether it still responds correctly and quickly, which is a different check than confirming a single request returns the right answer, the only thing your normal test suite actually covers. A load test asks whether your code stays correct, and fast, when a lot of people use it at once. Those are genuinely separate questions, and passing every unit test tells you nothing about the second one.

k6 is the tool I reached for afterward, mostly because it’s free, open source, and you write the tests in JavaScript instead of learning some XML-based DSL from the JMeter era. It’s made by Grafana Labs, which matters later when you want to actually watch a test run instead of squinting at a terminal.

Installing it

On macOS:

brew install k6

On Debian or Ubuntu:

curl -fsSL https://dl.k6.io/key.gpg | sudo gpg --dearmor -o /usr/share/keyrings/k6-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main" | sudo tee /etc/apt/sources.list.d/k6.list
sudo apt-get update
sudo apt-get install k6

Or skip installing anything and run it through Docker:

docker pull grafana/k6

I’d install it locally rather than run it through Docker for anything beyond a one-off test. You’ll be rerunning scripts constantly while you tune them, and the Docker invocation gets old fast.

Your first script

k6 tests are plain JavaScript files. This is the entire thing you need to hit an endpoint and check it worked:

// script.js
import http from "k6/http";
import { check, sleep } from "k6";

export default function () {
  const res = http.get("https://test.k6.io");

  check(res, {
    "status is 200": (r) => r.status === 200,
  });

  sleep(1);
}

Run it with:

k6 run script.js

http.get fires the request. check() is not the same thing as an assertion in a normal test framework, a failed check doesn’t stop the test or make k6 exit with a non-zero status on its own, it just gets recorded and shows up in the summary as a pass/fail rate. That surprised me the first time, I expected a failed check to behave like a failed expect(). It doesn’t, and that’s on purpose, k6 wants the run to keep going so you get full results even when something’s broken. sleep(1) matters more than it looks like it should too, without it every virtual user fires requests back to back with no gap, which isn’t how real users behave and will make your results look worse than reality.

What the default output is actually telling you

k6’s default output is a summary block printed at the end of the run that reports the metrics that actually matter, request duration, the percentage of failed requests, how many iterations ran, and how many virtual users were active, so you can tell what happened without digging through raw logs. The fields worth paying attention to first time through:

Duration gets broken down into avg, min, med, max, and percentiles like p(90) and p(95). The average is the number everyone looks at first and the one that lies to you most. If 95 requests take 100ms and 5 take 8 seconds, your average looks fine and your p95 tells you something is actually wrong for a meaningful chunk of users. Get in the habit of reading the percentile columns, not the average.

One virtual user for a few seconds isn’t a load test

The script above runs with a single virtual user by default, which is really just a smoke test wearing a load testing tool’s clothes. The part that actually simulates load is ramping the number of virtual users up over time, which k6 calls stages:

export const options = {
  stages: [
    { duration: "30s", target: 20 },
    { duration: "1m", target: 20 },
    { duration: "30s", target: 0 },
  ],
};

Read that as three phases. Ramp up to 20 virtual users over 30 seconds, hold at 20 for a minute, then ramp back down to zero over 30 seconds. That ramp-down matters, jumping straight from load to zero can hide connection-draining problems that show up as errors right at the end of a real traffic spike, when people are still leaving the page open.

Twenty virtual users isn’t a lot. What surprised me is how quickly you find a problem anyway, the endpoint that fell over during that email blast started showing degraded p95 numbers at around fifteen concurrent users in a follow-up test, well before anything looked broken to a human eye watching it manually.

Thresholds are what make the test actually fail

A threshold is a rule like “p95 response time must stay under 500ms” that you attach to a metric in the options block, and it’s the piece that actually makes k6 exit with a non-zero status code when it’s broken, which is the thing you want in CI.

export const options = {
  stages: [
    { duration: "30s", target: 20 },
    { duration: "1m", target: 20 },
    { duration: "30s", target: 0 },
  ],
  thresholds: {
    http_req_duration: ["p(95)<500"],
    http_req_failed: ["rate<0.01"],
  },
};

That says: 95% of requests need to finish under 500ms, and fewer than 1% of requests are allowed to fail. If either one breaks, k6 exits non-zero. Wire that into a CI step after a deploy to staging and you get an actual gate instead of a test that just prints numbers nobody reads.

Watching it live instead of squinting at a terminal

For a while I just stared at the scrolling terminal output during a run, which works but tells you nothing about the shape of what happened over time. k6 has a built-in web dashboard for this, no extra setup required:

K6_WEB_DASHBOARD=true k6 run script.js

That serves a live dashboard at http://127.0.0.1:5665 while the test runs, with request rate and response time graphs updating as it goes. You can export a static HTML report from the same run:

K6_WEB_DASHBOARD=true K6_WEB_DASHBOARD_EXPORT=report.html k6 run script.js

Worth knowing the report only fills in with graphs if the test runs long enough relative to its aggregation window, a five-second test against the default settings will give you a mostly empty report. Give it at least a minute or two.

Sending results into an actual Grafana instance

Yes, you can pipe k6 results straight into Grafana: k6 has a built-in experimental output that streams every metric to Prometheus over its remote write API, and Grafana just reads from Prometheus like it would for any other data source. Worth setting up if you’re running these regularly and want to compare results over time, or you already have Grafana running for other things anyway:

K6_PROMETHEUS_RW_SERVER_URL=http://localhost:9090/api/v1/write \
  k6 run -o experimental-prometheus-rw script.js

Every metric shows up in Prometheus prefixed with k6_, and Grafana Labs publishes a ready-made dashboard you can import against that data instead of building panels from scratch. This is the “experimental” output as of the current docs, the flag name itself might change in a future release, worth checking the docs if this stops working exactly as written here.

If you don’t want to run and maintain your own Prometheus, Grafana Cloud k6 does the same job as a hosted service, at the cost of it no longer being free. For a side project I’d start with the local Prometheus setup or even just the built-in web dashboard. For a team already paying for Grafana Cloud, the hosted option removes one more thing to operate.

Where I’d actually start

Don’t try to model your entire user journey on day one. Pick the one or two endpoints that would actually hurt if they fell over, the login route, the checkout call, whatever your version of that eight-thousand-person email blast is, and write a script for those first. A load test that covers three critical endpoints and actually runs in CI beats an ambitious one covering everything that nobody maintains after the first week.


References


Share this post: