Guides

k6 API Load Testing Tutorial: Quick Start with Scripts (2026)

Parveen KumariUpdated Aug 19, 202611 min read

Quick answer

k6 is a CLI-first, JavaScript-scripted load testing tool: write a script defining virtual users and the request to send, run it with k6 run, and use check() for per-request pass/fail plus thresholds to gate the run's exit code on p95 latency or error rate. It answers the same question JMeter does — does the API hold up under load — but in plain JavaScript with no GUI, which Node.js teams typically find faster to adopt and run in CI/CD.

Reviewed by Rishi Gaurav

Share:
Line chart showing throughput and error rate as virtual users ramp up, marking where a k6 threshold breaks

k6 is an open-source load testing tool from Grafana Labs: you write a JavaScript script describing virtual users and the requests they send, run it from the command line with k6 run, and k6 measures response time, throughput, and error rate under that simulated concurrency. It answers the same question as JMeter — does the API hold up under load — but as a CLI-first tool scripted in plain JavaScript, with no GUI step required at any point.

This guide covers a real k6 script from scratch: virtual users and duration, check() assertions, thresholds that gate CI on latency and error rate, staged ramp-up, POST requests, and running it all in a GitHub Actions pipeline. Every script runs as written against JSONPlaceholder, a free public fake REST API.

Table of Contents

  1. Why k6 for Load Testing
  2. What You Need
  3. Your First k6 Script
  4. check() vs Thresholds
  5. Staged Ramp-Up
  6. Testing POST Requests
  7. Validating Response Content
  8. Running k6 in CI/CD with GitHub Actions
  9. Reading the Output
  10. Common Pitfalls in k6 Scripts
  11. k6 vs JMeter vs Functional API Testing
  12. FAQ

Why k6 for Load Testing

k6's core design decision is to skip the GUI entirely: a load test is a JavaScript file, checked into version control like any other code, run with a single CLI command both locally and in CI. That makes it a natural fit for teams already comfortable scripting in JavaScript or Node.js — there is no separate authoring tool to learn, and no .jmx XML file to diff in a pull request.

Its scripting model is intentionally minimal rather than full Node.js: k6 runs your script in its own Go-based JavaScript runtime, which supports ES2015+ syntax and k6's built-in modules (k6/http, k6/metrics, k6/data, etc.) but not Node's require() for arbitrary npm packages without a custom build. That tradeoff keeps k6 itself fast and dependency-light.

k6 load testing stack showing JS script and virtual users, check and thresholds, and CI/CD stages

What You Need

Install k6 for your platform:

# macOS
brew install k6

# Windows
choco install k6

# Linux — see k6.io/docs/get-started/installation for your distro

Check k6's installation docs for the current instructions on your platform — package manager commands and repository keys change over time.

Your First k6 Script

// load-test.js
import http from 'k6/http';
import { check, sleep } from 'k6';

export const options = {
  vus: 10,
  duration: '30s',
};

export default function () {
  const res = http.get('https://jsonplaceholder.typicode.com/users/1');
  check(res, {
    'status is 200': (r) => r.status === 200,
  });
  sleep(1);
}

Run it:

k6 run load-test.js

vus: 10 means 10 virtual users, each running the default function in a loop for the full duration. sleep(1) between iterations approximates think time between real user actions — remove it only if you deliberately want to hammer the endpoint as fast as possible.

check() vs Thresholds

These serve different purposes and are easy to conflate. check() records a pass/fail for an individual assertion on an individual response — it does not stop the test run or fail the CI build by itself, it just contributes to a percentage shown in the summary.

export const options = {
  vus: 10,
  duration: '30s',
  thresholds: {
    http_req_duration: ['p(95)<500'],  // 95th percentile under 500ms
    http_req_failed: ['rate<0.01'],    // less than 1% request failure rate
    checks: ['rate>0.99'],             // at least 99% of checks must pass
  },
};

export default function () {
  const res = http.get('https://jsonplaceholder.typicode.com/users/1');
  check(res, {
    'status is 200': (r) => r.status === 200,
    'has email field': (r) => JSON.parse(r.body).email !== undefined,
  });
}

thresholds is what actually gates CI: if any threshold is breached, k6 run exits with a non-zero status code, and a normal CI step failure follows automatically.

Staged Ramp-Up

A flat vus/duration pair jumps straight to full concurrency, which rarely matches real traffic. stages ramps linearly between target VU counts:

Ready to shift left with your API testing?

Try our no-code API test automation platform free. Generate tests from OpenAPI, run in CI/CD, and scale quality.

export const options = {
  stages: [
    { duration: '10s', target: 10 },  // ramp up to 10 VUs
    { duration: '30s', target: 10 },  // hold at 10 VUs
    { duration: '10s', target: 0 },   // ramp down to 0
  ],
  thresholds: {
    http_req_duration: ['p(95)<500'],
  },
};

stages and vus/duration are mutually exclusive in the same options object — use stages whenever you want anything more realistic than an instant flat load.

Testing POST Requests

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

export const options = {
  vus: 5,
  duration: '15s',
};

export default function () {
  const payload = JSON.stringify({ title: 'foo', body: 'bar', userId: 1 });
  const params = {
    headers: { 'Content-Type': 'application/json' },
  };

  const res = http.post('https://jsonplaceholder.typicode.com/posts', payload, params);
  check(res, {
    'status is 201': (r) => r.status === 201,
  });
}

Unlike requests in Python or request.post() in Playwright, k6's http.post() does not auto-serialize a plain object — pass a JSON string via JSON.stringify() and set Content-Type explicitly in params.headers.

Validating Response Content

k6's idiomatic pattern for response validation is manual field checks inside check(), not a bundled JSON Schema validator:

check(res, {
  'status is 200': (r) => r.status === 200,
  'has id': (r) => JSON.parse(r.body).id !== undefined,
  'email contains @': (r) => JSON.parse(r.body).email.includes('@'),
  'response time OK': (r) => r.timings.duration < 500,
});

Note the last check mixes a functional assertion (email contains @) with a performance one (response time OK) in the same block — k6 does not separate the two the way a functional-only tool would, since under-load correctness and speed are usually evaluated together. JSON.parse(r.body) runs once per check unless you cache it in a variable first — for a hot loop, parse once and reuse the object across multiple checks.

Running k6 in CI/CD with GitHub Actions

# .github/workflows/load-test.yml
name: API Load Test

on: [workflow_dispatch, pull_request]

jobs:
  k6-load-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run k6 load test
        uses: grafana/k6-action@v0.3.1
        with:
          filename: load-test.js

Check the grafana/k6-action listing on the GitHub Marketplace for the current version tag before pinning. workflow_dispatch lets you trigger the load test manually on demand; gating it to pull_request as well runs it on every PR — most teams reserve full load tests for PRs or a nightly schedule rather than every commit, given how much longer they run than a functional suite.

Reading the Output

k6's terminal summary at the end of a run reports the metrics that matter most:

  • http_req_duration — response time, broken into avg/min/med/max/p90/p95
  • http_req_failed — the percentage of requests that errored
  • checks — the percentage of check() assertions that passed
  • iterations — how many full script iterations completed across all VUs

A threshold breach is marked directly in this summary with a red , alongside the green for thresholds that passed — the pass/fail state is visible without parsing separate log output.

Common Pitfalls in k6 Scripts

  • Confusing check() with thresholds. A failing check() alone does not fail the CI build — only a breached threshold does.
  • Using vus/duration when you meant stages. A flat load rarely represents real traffic; use stages for a ramp.
  • Re-parsing JSON.parse(r.body) in every check. Parse once into a variable and reuse it across multiple assertions in a hot loop.
  • Setting thresholds too loose to ever fail. A threshold that never triggers provides no actual CI gate — set it based on a real SLA, not an arbitrarily generous number.
  • Forgetting sleep() between iterations. Without it, k6 hammers the endpoint as fast as each VU's loop can execute, which may not represent realistic user behavior (though it's sometimes intentional for stress testing).
  • Running a full load test on every single commit. Reserve it for PRs or a schedule — it takes meaningfully longer than a functional suite.

Free Guided worksheet

Build Your Testing Strategy in 30 Minutes

A structured worksheet that walks you through defining your testing strategy in 30 minutes. Cover architecture, tools, layers, and team responsibilities.

Download Free

k6 vs JMeter vs Functional API Testing

ApproachWhat it measuresLanguageCI/CD fitBest for
k6Load, throughput, latency under concurrencyJavaScriptk6 run in CI, official GitHub ActionJavaScript-native teams wanting a lighter, CLI-first load tool
JMeterLoad, throughput, latency under concurrencyGUI + XML (.jmx), headless CLIjmeter -n -t in CITeams needing multi-protocol load testing and a mature plugin ecosystem
pytest / REST Assured / PlaywrightFunctional correctness of a single responsePython / Java / TypeScriptNative — same runner as unit testsVerifying each endpoint's business logic, independent of load
AI-generated (Total Shift Left)Functional correctness, generated from specNone requiredNative CI pluginsKeeping functional coverage complete as the API grows, freeing k6 to focus purely on load

Like JMeter, k6 and a functional suite measure different things and belong in the same pipeline rather than competing for the same slot. See monitoring API performance in production for what to watch once the load test passes.

Frequently Asked Questions

What is k6 used for? Load and performance testing — a JavaScript script defines virtual users and requests, k6 run executes it, and k6 measures response time, throughput, and error rate under that load.

k6 vs JMeter — which should I use? k6 is JavaScript-native, CLI-first, and lighter weight. JMeter has a deeper GUI and supports more protocols beyond HTTP. Both run headless in CI — the choice usually comes down to team language preference.

How do I fail a CI build based on a k6 load test? Define thresholds in the options object (e.g. http_req_duration: ['p(95)<500']); k6 exits with a non-zero status code if any threshold is breached.

Does k6 support ramping load up and down gradually? Yes, via the stages array — each stage specifies a duration and a target VU count, and k6 ramps linearly between them.

How do I validate a JSON response body in k6? k6's idiomatic pattern is manual field checks inside check(), parsing the body with JSON.parse(), rather than a bundled JSON Schema validator.

Can k6 replace a functional API test suite? No. It confirms the API stays fast and mostly error-free under load, not that every endpoint's business logic is correct — keep a dedicated functional suite alongside it.

Key Takeaways

  • check() records pass/fail per assertion; only thresholds fail the CI build. Don't rely on checks alone to gate a pipeline.
  • Use stages for a realistic ramp, not a flat vus/duration jump to full concurrency.
  • k6's runtime is not full Node.js — no require() for arbitrary npm packages, which is why schema validation is typically manual field checks.
  • k6 run exits non-zero on a threshold breach, so CI integration needs no extra parsing logic.
  • Reserve full load tests for PRs or a schedule, not every commit — they run far longer than a functional suite.
  • k6 and a functional suite measure different things. Neither replaces the other; both belong in the same pipeline.

Keep Functional Coverage Complete While k6 Handles Load

k6 tells you whether your API holds up under load. It was never meant to enumerate every functional case across every endpoint — that is a separate, ongoing maintenance burden as your API grows. Total Shift Left generates the functional suite directly from your OpenAPI spec — positive, negative, and boundary cases for every endpoint — so your team's hand-written scripting effort goes into the load and performance testing k6 is actually built for.

Start your free trial to see the generated functional suite, or see plans and pricing if you're already evaluating.

Ready to shift left with your API testing?

Try our no-code API test automation platform free.