What Is k6?

k6 is a modern, open-source load testing tool built by Grafana Labs. Unlike JMeter’s GUI-driven approach, k6 uses JavaScript scripts that you write in your code editor and run from the command line. This makes it a natural fit for developers and automation engineers who prefer code over configuration.

k6 is written in Go, which gives it excellent performance characteristics. A single machine running k6 can simulate thousands of virtual users with low resource consumption compared to JMeter. The tool integrates naturally into CI/CD pipelines, making it ideal for shift-left performance testing.

k6 vs JMeter: When to Use Which

Before diving into k6, it helps to understand where it shines compared to JMeter.

Featurek6JMeter
ScriptingJavaScript (code)GUI + XML
Resource usageLow (Go runtime)High (Java/JVM)
Protocol supportHTTP, WebSocket, gRPCHTTP, JDBC, FTP, SMTP, LDAP, JMS
CI/CD integrationNative (CLI-first)Requires plugins
Distributed testingk6 Cloud or xk6-distributedBuilt-in master/slave
Browser testingk6 browser moduleNot supported
Learning curveEasy if you know JSModerate (GUI-based)
CommunityGrowing fastVery large, mature

Choose k6 when: Your team knows JavaScript, you need CI/CD integration, you test HTTP/gRPC/WebSocket APIs, or you want lightweight local execution.

Choose JMeter when: You need protocol support beyond HTTP (JDBC, FTP, JMS), your team prefers a GUI, or you need an established ecosystem with extensive plugins.

Installing k6

# macOS
brew install k6

# Windows
choco install k6

# Linux (Debian/Ubuntu)
sudo gpg -k
sudo gpg --no-default-keyring --keyring /usr/share/keyrings/k6-archive-keyring.gpg --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D68
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

# Docker
docker run --rm -i grafana/k6 run - <script.js

Verify the installation:

k6 version

Your First k6 Script

Here is a basic k6 script that sends HTTP GET requests:

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

export const options = {
  vus: 10,        // 10 virtual users
  duration: '30s', // run for 30 seconds
};

export default function () {
  const res = http.get('https://test-api.k6.io/public/crocodiles/');

  check(res, {
    'status is 200': (r) => r.status === 200,
    'response time < 500ms': (r) => r.timings.duration < 500,
    'body contains crocodiles': (r) => r.body.includes('crocodiles'),
  });

  sleep(1); // wait 1 second between iterations
}

Run it:

k6 run script.js

Script Structure

Every k6 script has three key parts:

  1. Imports: Load k6 modules (k6/http, k6, k6/metrics)
  2. Options: Configure VUs, duration, stages, thresholds
  3. Default function: The code each VU executes in a loop

The default function runs once per iteration for each VU. With 10 VUs and a 1-second sleep, you get roughly 10 requests per second.

Virtual Users (VUs) and Iterations

A Virtual User (VU) is a concurrent execution thread. Each VU runs the default function in a loop until the test duration expires or the iteration count is reached.

  • VUs: Number of concurrent users
  • Iterations: Total number of times the default function executes (across all VUs)
  • Duration: How long the test runs

You can specify either iterations or duration, but not both with fixed VU counts.

// Fixed iterations: 100 total iterations shared across 10 VUs
export const options = {
  vus: 10,
  iterations: 100,
};

// Duration-based: 10 VUs running for 1 minute
export const options = {
  vus: 10,
  duration: '1m',
};

Stages: Ramp-Up, Steady State, Ramp-Down

For realistic load patterns, use stages to change the VU count over time:

export const options = {
  stages: [
    { duration: '2m', target: 50 },   // ramp up to 50 VUs over 2 minutes
    { duration: '5m', target: 50 },   // stay at 50 VUs for 5 minutes
    { duration: '2m', target: 0 },    // ramp down to 0 over 2 minutes
  ],
};

This creates a classic load test profile: gradual increase, sustained load, and graceful decrease.

Thresholds: Pass/Fail Criteria

Thresholds define success criteria for your test. If any threshold is breached, k6 exits with a non-zero exit code — perfect for CI/CD pipelines.

export const options = {
  thresholds: {
    http_req_duration: ['p(95)<500'],     // 95% of requests under 500ms
    http_req_failed: ['rate<0.01'],        // less than 1% failure rate
    http_reqs: ['rate>100'],               // at least 100 requests/second
    checks: ['rate>0.99'],                 // 99% of checks pass
  },
};

Common threshold metrics:

  • http_req_duration — Response time (p50, p90, p95, p99, avg, max)
  • http_req_failed — Percentage of failed requests
  • http_reqs — Request rate (requests per second)
  • checks — Percentage of passed checks

Checks: Inline Assertions

Checks verify response data without stopping the test (unlike assertions in JMeter that mark requests as failed):

check(res, {
  'status is 200': (r) => r.status === 200,
  'body has expected field': (r) => JSON.parse(r.body).hasOwnProperty('id'),
  'response time OK': (r) => r.timings.duration < 300,
});

Checks are recorded as metrics. You can set thresholds on check pass rates.

Working with HTTP Methods

import http from 'k6/http';

// GET
const getRes = http.get('https://api.example.com/users');

// POST with JSON body
const payload = JSON.stringify({ name: 'Test User', email: 'test@example.com' });
const params = { headers: { 'Content-Type': 'application/json' } };
const postRes = http.post('https://api.example.com/users', payload, params);

// PUT
const putRes = http.put('https://api.example.com/users/1', payload, params);

// DELETE
const delRes = http.del('https://api.example.com/users/1');

// Batch requests (parallel)
const responses = http.batch([
  ['GET', 'https://api.example.com/users'],
  ['GET', 'https://api.example.com/products'],
  ['GET', 'https://api.example.com/orders'],
]);

Exercise: Multi-Endpoint Load Test with k6

Write a k6 script that tests an e-commerce API with authentication, multiple endpoints, thresholds, and checks.

Scenario

Test an e-commerce API with the following flow:

  1. Login to get an auth token
  2. Browse product catalog
  3. View a specific product
  4. Add product to cart

Requirements

  1. Use stages: ramp to 25 VUs over 1 minute, hold for 3 minutes, ramp down over 1 minute
  2. Set thresholds: p(95) < 800ms, error rate < 1%, checks pass rate > 99%
  3. Add checks for status codes and response body content
  4. Use the auth token in subsequent requests
  5. Add realistic think time between requests
Hint: Script Structure
import http from 'k6/http';
import { check, sleep, group } from 'k6';

export const options = {
  stages: [
    { duration: '1m', target: 25 },
    { duration: '3m', target: 25 },
    { duration: '1m', target: 0 },
  ],
  thresholds: {
    // define your thresholds here
  },
};

export default function () {
  // Group 1: Login
  // Group 2: Browse catalog
  // Group 3: View product
  // Group 4: Add to cart
}

Use group() to organize requests into logical sections. This gives you per-group metrics in the results.

Solution: Complete k6 Script
import http from 'k6/http';
import { check, sleep, group } from 'k6';

const BASE_URL = 'https://api.ecommerce.example.com';

export const options = {
  stages: [
    { duration: '1m', target: 25 },  // ramp up
    { duration: '3m', target: 25 },  // steady state
    { duration: '1m', target: 0 },   // ramp down
  ],
  thresholds: {
    http_req_duration: ['p(95)<800'],
    http_req_failed: ['rate<0.01'],
    checks: ['rate>0.99'],
  },
};

export default function () {
  let token;

  // Step 1: Login
  group('Login', function () {
    const loginPayload = JSON.stringify({
      username: `user${__VU}@example.com`,
      password: 'testpass123',
    });

    const loginRes = http.post(`${BASE_URL}/api/auth/login`, loginPayload, {
      headers: { 'Content-Type': 'application/json' },
    });

    check(loginRes, {
      'login status is 200': (r) => r.status === 200,
      'login returns token': (r) => JSON.parse(r.body).token !== undefined,
    });

    token = JSON.parse(loginRes.body).token;
  });

  sleep(1);

  const authHeaders = {
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${token}`,
    },
  };

  // Step 2: Browse catalog
  let productId;
  group('Browse Catalog', function () {
    const catalogRes = http.get(`${BASE_URL}/api/products`, authHeaders);

    check(catalogRes, {
      'catalog status is 200': (r) => r.status === 200,
      'catalog returns products': (r) => JSON.parse(r.body).products.length > 0,
    });

    const products = JSON.parse(catalogRes.body).products;
    productId = products[Math.floor(Math.random() * products.length)].id;
  });

  sleep(2);

  // Step 3: View product details
  group('View Product', function () {
    const productRes = http.get(`${BASE_URL}/api/products/${productId}`, authHeaders);

    check(productRes, {
      'product status is 200': (r) => r.status === 200,
      'product has name': (r) => JSON.parse(r.body).name !== undefined,
      'product has price': (r) => JSON.parse(r.body).price > 0,
    });
  });

  sleep(1);

  // Step 4: Add to cart
  group('Add to Cart', function () {
    const cartPayload = JSON.stringify({
      productId: productId,
      quantity: 1,
    });

    const cartRes = http.post(`${BASE_URL}/api/cart`, cartPayload, authHeaders);

    check(cartRes, {
      'cart status is 200 or 201': (r) => r.status === 200 || r.status === 201,
      'cart confirms item added': (r) => JSON.parse(r.body).success === true,
    });
  });

  sleep(1);
}

Running the test:

k6 run ecommerce-load-test.js

What to look for in results:

  • http_req_duration — p95 should be under 800ms
  • http_req_failed — should be near 0%
  • checks — pass rate should be above 99%
  • http_reqs — total request rate
  • Per-group metrics show which API endpoint is slowest

Exporting results for analysis:

# JSON output
k6 run --out json=results.json ecommerce-load-test.js

# CSV output
k6 run --out csv=results.csv ecommerce-load-test.js

# InfluxDB (for Grafana dashboards)
k6 run --out influxdb=http://localhost:8086/k6 ecommerce-load-test.js

Pro Tips

  • Use __VU and __ITER: Built-in variables __VU (current VU number) and __ITER (current iteration number) help create unique data per user without external CSV files.
  • SharedArray for test data: Load large datasets once and share across VUs using SharedArray from k6/data. This prevents each VU from loading its own copy.
  • Custom metrics: Create custom metrics with Counter, Gauge, Rate, and Trend from k6/metrics to track business-specific KPIs.
  • Scenarios for complex patterns: Use scenarios instead of stages when you need multiple executor types (constant-vus, ramping-vus, per-vu-iterations, constant-arrival-rate) running simultaneously.
  • k6 browser module: For browser-based load testing, k6 now includes a Chromium-based browser module that can measure Core Web Vitals under load.