k6

k6

Definition: A developer-friendly load testing tool where tests are written as JavaScript scripts, used to simulate many concurrent virtual users hitting an API or website to measure how it behaves under load.

How It Works

  • A k6 script exports a default function; each iteration of that function is one simulated user action (an HTTP request, a wait, an assertion), executed repeatedly by however many virtual users (VUs) the test configures.
  • Despite being scripted in JavaScript, k6’s engine itself is written in Go and runs each VU as a lightweight goroutine, not a real browser or a real JS runtime per user, which is why it can simulate thousands of concurrent users from a single machine.
  • options.stages defines a ramping profile: how many VUs to ramp up to, over how long, how long to hold steady, and how to ramp down, modeling realistic traffic rather than a flat request flood.
  • check() validates a single response (status code, body content, response time) without failing the whole test run; it’s a soft per-request pass/fail counted into a percentage.
  • thresholds are hard pass/fail criteria evaluated across the whole run, like “95% of requests must complete under 200ms” or “error rate must stay under 1%.” Breaching a threshold gives k6 a non-zero exit code, which is what actually fails a CI pipeline.
  • Metrics (http_req_duration, http_req_failed, vus, custom metrics via Trend/Counter/Rate) are collected automatically per request and summarized at the end, or streamed live to Grafana, InfluxDB, Prometheus, or k6 Cloud.
  • Executors control the load pattern precisely: ramping-vus for gradual ramps, constant-arrival-rate for a fixed request rate regardless of how long responses take, per-vu-iterations for a fixed number of loops per VU.

Under the Hood

Given an API endpoint that needs to handle a ramping load test Step:

import http from 'k6/http'
import { check, sleep } from 'k6'

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

export default function () {
  const res = http.get('https://api.example.com/products')
  check(res, {
    'status is 200': (r) => r.status === 200,
    'has products array': (r) => JSON.parse(r.body).products.length > 0,
  })
  sleep(1)
}

Answer: k6 ramps from 0 to 50 VUs over 30 seconds, holds 50 for a minute, then ramps down over 20 seconds. Every VU loops the default function, hitting /products, checking the response, and sleeping 1 second to model think-time. If p95 latency exceeds 300ms or more than 1% of requests fail, the thresholds fail and k6 exits non-zero.

Given a need to test at a fixed request rate regardless of response time Step:

export const options = {
  scenarios: {
    steady_rate: {
      executor: 'constant-arrival-rate',
      rate: 100,
      timeUnit: '1s',
      duration: '2m',
      preAllocatedVUs: 50,
    },
  },
}

Answer: k6 fires exactly 100 iterations per second for 2 minutes, adding more VUs from the pre-allocated pool if responses start taking longer, so the request rate stays constant even as the system under test slows down, unlike ramping-vus, where slow responses reduce effective throughput.

Why It Matters

  • Tests are real, version-controlled JavaScript files, reviewable in a pull request the same way application code is, not a GUI project file that’s hard to diff.
  • Thresholds give load tests a real pass/fail signal, letting performance regressions block a CI pipeline the same way a failing unit test would.
  • Running as compiled Go rather than spinning up real browsers or JVM threads per user makes k6 dramatically cheaper to generate high concurrency from a single machine.
  • Distinguishing check() from thresholds lets a script report granular per-request success while still having one clear overall verdict for the whole run.

Common Pitfalls

  • Skipping sleep() between requests, producing an unrealistic request pattern that hammers the target far harder than real users ever would.
  • Not setting any thresholds, so the test always exits 0 regardless of how badly latency or error rate degraded, silently defeating the point of running it in CI.
  • Running a large test from one underpowered machine or constrained CI runner, which measures the load generator’s limits instead of the target system’s.
  • Conflating load testing with functional correctness testing; k6’s check() isn’t a substitute for a real test suite covering business logic edge cases.
  • Ignoring warm-up and ramp-down periods when reading percentile results, and hardcoding the same test data (one user ID, one search term) across every VU, both of which skew results away from real traffic patterns.

Comparison

k6JMeterGatlingLocust
ScriptingJavaScriptGUI-first, or Groovy/BeanShellScala DSLPython
EngineGo, goroutines per VUJava, thread per VUScala/Akka, actor-basedPython, greenlets or gevent
Resource efficiencyHigh, low memory per VULower, thread-heavy at scaleHighModerate, GIL-limited without workarounds
CI/CD friendlinessBuilt for it, thresholds as exit codesPossible, less naturalGood, code-basedGood, code-based
Best fitDeveloper-driven load testing in CILong-established enterprise QA teams, GUI usersScala-heavy teams wanting high throughputPython-heavy teams wanting a scriptable model

Example

import http from 'k6/http'
import { check } from 'k6'

export const options = {
  vus: 20,
  duration: '1m',
  thresholds: {
    http_req_duration: ['p(99)<500'],
  },
}

export default function () {
  const payload = JSON.stringify({ query: 'laptop' })
  const res = http.post('https://api.example.com/search', payload, {
    headers: { 'Content-Type': 'application/json' },
  })
  check(res, { 'search returned results': (r) => r.status === 200 })
}

Run with k6 run search-load-test.js, this holds 20 constant virtual users hitting a search endpoint for a minute; if the 99th percentile response time crosses 500ms, the run fails, exactly the kind of check a team wires into a pre-release CI gate.

  • Postman — covers functional correctness; k6 picks up where it leaves off, for load and performance
  • Rate Limiting — the server-side mechanism a k6 test often ends up validating or triggering
  • Load Balancer — what distributes the traffic k6 generates across backend instances
  • CI-CD — where k6 thresholds act as an automated performance gate before deploy
  • REST API — the typical target of a k6 script’s HTTP requests

Dig deeper