Testing

Load Testing with k6: A Practical Guide

Complete guide to load testing Node.js applications with k6 covering scenarios, thresholds, custom metrics, authentication, CI/CD integration, and result analysis.

Load Testing with k6: A Practical Guide

Overview

k6 is an open-source load testing tool built by Grafana Labs that lets you write performance tests in JavaScript and execute them with a high-performance Go runtime. Unlike tools that bolt on scripting as an afterthought, k6 was designed from the ground up for developers who want to write load tests the same way they write application code -- with real logic, reusable modules, and version-controlled test suites. If you ship a Node.js API and you are not load testing it before production, you are just hoping it works under traffic. Hope is not a strategy.

Prerequisites

  • k6 installed (k6.io/docs/getting-started/installation)
  • Node.js 18+ installed
  • An Express.js application to test (we will build one)
  • Basic familiarity with HTTP, REST APIs, and JSON
  • Docker installed (optional, for running k6 in containers)
  • A GitHub or Azure DevOps account (for CI/CD integration sections)

k6 Installation and Basic Concepts

Installation

On macOS:

brew install k6

On Windows:

choco install k6

On Ubuntu/Debian:

sudo gpg -k
sudo gpg --no-default-keyring --keyring /usr/share/keyrings/k6-archive-keyring.gpg --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69
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 run it via Docker:

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

Verify your installation:

k6 version
k6 v0.54.0 (go1.22.5, linux/amd64)

Core Concepts

k6 revolves around three fundamental concepts:

Virtual Users (VUs) -- Each VU is an independent execution thread that runs your test script in a loop. A VU simulates a single user making requests. 50 VUs means 50 concurrent users hammering your API simultaneously.

Iterations -- One complete execution of your test function. Each VU runs multiple iterations during the test. If a VU's iteration takes 200ms, it will complete roughly 5 iterations per second.

Duration -- How long the test runs. You can specify a fixed duration (30s, 5m, 1h) or a fixed number of iterations. Duration-based tests are more common because they simulate sustained traffic patterns.


Writing Your First Load Test Script

k6 scripts use ES module syntax -- this is how k6's JavaScript runtime works, not Node.js. Create a file called load-test.js:

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

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

export default function () {
  var res = http.get('http://localhost:3000/api/health');

  check(res, {
    'status is 200': function (r) { return r.status === 200; },
    'response time < 200ms': function (r) { return r.timings.duration < 200; },
  });

  sleep(1);
}

Run it:

k6 run load-test.js
          /\      |‾‾| /‾‾/   /‾‾/
     /\  /  \     |  |/  /   /  /
    /  \/    \    |     (   /   ‾‾\
   /          \   |  |\  \ |  (‾)  |
  / __________ \  |__| \__\ \_____/ .io

  execution: local
     script: load-test.js
     output: -

  scenarios: (100.00%) 1 scenario, 10 max VUs, 1m0s max duration (incl. graceful stop):
           * default: 10 looping VUs for 30s (gracefulStop: 30s)


running (0m30.0s), 00/10 VUs, 285 complete and 0 interrupted iterations
default ✓ [======================================] 10 VUs  30s

     ✓ status is 200
     ✓ response time < 200ms

     checks.........................: 100.00% ✓ 570       ✗ 0
     data_received..................: 68 kB   2.3 kB/s
     data_sent......................: 24 kB   812 B/s
     http_req_blocked...............: avg=23µs    min=1µs    med=4µs    max=2.1ms  p(90)=7µs    p(95)=11µs
     http_req_connecting............: avg=14µs    min=0s     med=0s     max=1.8ms  p(90)=0s     p(95)=0s
     http_req_duration..............: avg=4.32ms  min=1.2ms  med=3.8ms  max=42ms   p(90)=7.1ms  p(95)=9.5ms
       { expected_response:true }...: avg=4.32ms  min=1.2ms  med=3.8ms  max=42ms   p(90)=7.1ms  p(95)=9.5ms
     http_req_failed................: 0.00%   ✓ 0         ✗ 285
     http_req_receiving.............: avg=42µs    min=8µs    med=34µs   max=1.2ms  p(90)=78µs   p(95)=102µs
     http_req_sending...............: avg=18µs    min=3µs    med=14µs   max=892µs  p(90)=28µs   p(95)=38µs
     http_req_tls_handshaking.......: avg=0s      min=0s     med=0s     max=0s     p(90)=0s     p(95)=0s
     http_req_waiting...............: avg=4.26ms  min=1.1ms  med=3.7ms  max=41ms   p(90)=7ms    p(95)=9.3ms
     http_reqs......................: 285     9.499869/s
     iteration_duration.............: avg=1s      min=1s     med=1s     max=1.04s  p(90)=1.01s  p(95)=1.01s
     iterations.....................: 285     9.499869/s
     vus............................: 10      min=10      max=10
     vus_max........................: 10      min=10      max=10

The key metrics to focus on: http_req_duration is the total request time (p90 and p95 are what matter), http_req_failed is your error rate, and http_reqs is throughput. Everything else is diagnostic.


Test Lifecycle Stages

k6 provides three lifecycle hooks: setup, the default function (your actual test), and teardown. This mirrors how you structure real test suites.

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

export var options = {
  vus: 20,
  duration: '1m',
};

// Runs once before test starts. Return value is passed to default and teardown.
export function setup() {
  var loginRes = http.post('http://localhost:3000/api/auth/login', JSON.stringify({
    email: '[email protected]',
    password: 'testpassword123',
  }), {
    headers: { 'Content-Type': 'application/json' },
  });

  var token = JSON.parse(loginRes.body).token;
  return { token: token };
}

// Runs once per iteration per VU. `data` is the return value from setup().
export default function (data) {
  var res = http.get('http://localhost:3000/api/articles', {
    headers: { 'Authorization': 'Bearer ' + data.token },
  });

  check(res, {
    'authenticated request succeeded': function (r) { return r.status === 200; },
  });
}

// Runs once after all VUs finish. `data` is the return value from setup().
export function teardown(data) {
  http.post('http://localhost:3000/api/auth/logout', null, {
    headers: { 'Authorization': 'Bearer ' + data.token },
  });
}

Important: setup() and teardown() each run exactly once, regardless of VU count. They run in a special VU that does not count toward your configured VUs. The data returned from setup() is serialized to JSON and deserialized for each VU -- you cannot pass functions or complex objects.


Load Test Scenarios

k6 scenarios give you fine-grained control over how VUs are scheduled. This is where k6 separates itself from simpler tools. Instead of one flat load profile, you can define multiple named scenarios that run concurrently with different traffic patterns.

Ramp-Up Test

Gradually increase load to find the breaking point:

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

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

export default function () {
  var res = http.get('http://localhost:3000/api/articles');
  check(res, {
    'status is 200': function (r) { return r.status === 200; },
  });
  sleep(1);
}

Spike Test

Simulate a sudden burst of traffic -- a product launch, a social media mention, a DDoS:

export var options = {
  stages: [
    { duration: '1m', target: 10 },    // warm up
    { duration: '10s', target: 500 },  // spike to 500 VUs in 10 seconds
    { duration: '2m', target: 500 },   // hold the spike
    { duration: '10s', target: 10 },   // drop back down
    { duration: '2m', target: 10 },    // recovery period
    { duration: '30s', target: 0 },    // ramp down
  ],
};

Soak Test

Run at moderate load for an extended period to detect memory leaks and connection pool exhaustion:

export var options = {
  stages: [
    { duration: '5m', target: 30 },    // ramp up
    { duration: '4h', target: 30 },    // hold for 4 hours
    { duration: '5m', target: 0 },     // ramp down
  ],
};

Multiple Named Scenarios

Run different workloads concurrently to simulate realistic mixed traffic:

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

export var options = {
  scenarios: {
    browse_articles: {
      executor: 'ramping-vus',
      startVUs: 0,
      stages: [
        { duration: '2m', target: 30 },
        { duration: '5m', target: 30 },
        { duration: '1m', target: 0 },
      ],
      exec: 'browseArticles',
    },
    create_contacts: {
      executor: 'constant-arrival-rate',
      rate: 5,
      timeUnit: '1s',
      duration: '8m',
      preAllocatedVUs: 10,
      maxVUs: 20,
      exec: 'submitContact',
    },
  },
};

export function browseArticles() {
  var res = http.get('http://localhost:3000/api/articles');
  check(res, { 'browse 200': function (r) { return r.status === 200; } });
  sleep(Math.random() * 3 + 1); // 1-4 second think time
}

export function submitContact() {
  var payload = JSON.stringify({
    name: 'Load Test User',
    email: 'loadtest' + Math.floor(Math.random() * 10000) + '@test.com',
    message: 'This is an automated load test submission.',
  });

  var res = http.post('http://localhost:3000/api/contact', payload, {
    headers: { 'Content-Type': 'application/json' },
  });

  check(res, { 'contact 201': function (r) { return r.status === 201; } });
}

The constant-arrival-rate executor is particularly valuable -- it maintains a fixed request rate regardless of response time. If your server slows down, k6 spawns more VUs to maintain the target rate, which is exactly how real traffic behaves.


Testing REST APIs and Express.js Endpoints

Here is a sample Express.js API we will test against. This is Node.js code, so it uses var, require, and function() syntax:

var express = require('express');
var jwt = require('jsonwebtoken');
var app = express();

app.use(express.json());

var JWT_SECRET = 'load-test-secret-key';

var articles = [
  { id: 1, title: 'API Gateway Patterns', category: 'architecture' },
  { id: 2, title: 'Docker Multi-Stage Builds', category: 'devops' },
  { id: 3, title: 'PostgreSQL Indexing', category: 'databases' },
];

app.get('/api/health', function (req, res) {
  res.json({ status: 'ok', uptime: process.uptime() });
});

app.post('/api/auth/login', function (req, res) {
  var email = req.body.email;
  var password = req.body.password;

  if (email === '[email protected]' && password === 'testpassword123') {
    var token = jwt.sign({ email: email, role: 'tester' }, JWT_SECRET, { expiresIn: '1h' });
    return res.json({ token: token });
  }
  res.status(401).json({ error: 'Invalid credentials' });
});

function authMiddleware(req, res, next) {
  var authHeader = req.headers.authorization;
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'No token provided' });
  }
  try {
    var decoded = jwt.verify(authHeader.split(' ')[1], JWT_SECRET);
    req.user = decoded;
    next();
  } catch (err) {
    res.status(403).json({ error: 'Invalid token' });
  }
}

app.get('/api/articles', function (req, res) {
  var category = req.query.category;
  var results = category ? articles.filter(function (a) { return a.category === category; }) : articles;
  res.json({ articles: results, total: results.length });
});

app.get('/api/articles/:id', function (req, res) {
  var article = articles.find(function (a) { return a.id === parseInt(req.params.id); });
  if (!article) return res.status(404).json({ error: 'Not found' });
  res.json(article);
});

app.post('/api/articles', authMiddleware, function (req, res) {
  var newArticle = {
    id: articles.length + 1,
    title: req.body.title,
    category: req.body.category,
  };
  articles.push(newArticle);
  res.status(201).json(newArticle);
});

app.listen(3000, function () {
  console.log('Test API running on port 3000');
});

Now the k6 test that exercises these endpoints:

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

var BASE_URL = 'http://localhost:3000';

export var options = {
  vus: 25,
  duration: '2m',
};

export default function () {
  group('Health Check', function () {
    var res = http.get(BASE_URL + '/api/health');
    check(res, {
      'health status 200': function (r) { return r.status === 200; },
      'health has uptime': function (r) { return JSON.parse(r.body).uptime > 0; },
    });
  });

  group('List Articles', function () {
    var res = http.get(BASE_URL + '/api/articles');
    check(res, {
      'articles status 200': function (r) { return r.status === 200; },
      'articles returns array': function (r) { return JSON.parse(r.body).articles.length > 0; },
    });
  });

  group('Filter by Category', function () {
    var res = http.get(BASE_URL + '/api/articles?category=architecture');
    check(res, {
      'filtered status 200': function (r) { return r.status === 200; },
      'filter returns results': function (r) { return JSON.parse(r.body).total >= 1; },
    });
  });

  group('Get Single Article', function () {
    var res = http.get(BASE_URL + '/api/articles/1');
    check(res, {
      'single article 200': function (r) { return r.status === 200; },
      'correct article': function (r) { return JSON.parse(r.body).id === 1; },
    });
  });

  group('404 Handling', function () {
    var res = http.get(BASE_URL + '/api/articles/999');
    check(res, {
      'missing article 404': function (r) { return r.status === 404; },
    });
  });

  sleep(1);
}

The group() function organizes your checks in the output and lets you see per-endpoint metrics. This is critical when you have 15 endpoints and need to figure out which one is slow.


Thresholds and Pass/Fail Criteria

Thresholds turn your load test from "let me look at the numbers" into an automated gate that passes or fails. This is what makes k6 usable in CI/CD.

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

export var options = {
  stages: [
    { duration: '1m', target: 50 },
    { duration: '3m', target: 50 },
    { duration: '1m', target: 0 },
  ],
  thresholds: {
    http_req_duration: ['p(95)<300', 'p(99)<500'],     // 95th percentile under 300ms, 99th under 500ms
    http_req_failed: ['rate<0.01'],                     // less than 1% failed requests
    http_reqs: ['rate>100'],                            // at least 100 requests per second throughput
    checks: ['rate>0.99'],                              // 99% of checks must pass
    'http_req_duration{name:articles}': ['p(95)<250'],  // per-endpoint threshold
  },
};

export default function () {
  var res = http.get('http://localhost:3000/api/articles', {
    tags: { name: 'articles' },
  });

  check(res, {
    'status 200': function (r) { return r.status === 200; },
  });

  sleep(1);
}

When a threshold fails, k6 exits with a non-zero exit code. The output clearly marks which thresholds passed and which failed:

     ✓ http_req_duration.............: avg=45ms  min=2ms  med=38ms  max=890ms  p(90)=112ms  p(95)=187ms  p(99)=423ms
     ✗ http_req_failed................: 2.34%  ✓ 47  ✗ 1963
       ✗ rate<0.01
         threshold crossed: 0.023 > 0.01
     ✓ http_reqs......................: 2010   134.0/s
     ✓ checks.........................: 97.66% ✓ 1963  ✗ 47
       ✗ rate>0.99
         threshold crossed: 0.9766 > 0.99

The exit code will be non-zero, which fails your CI pipeline. This is the behavior you want.


Custom Metrics and Tags

The built-in metrics cover the common cases, but sometimes you need to measure business-specific things: how long does the search query take versus the article fetch? What is the 95th percentile for authenticated vs. unauthenticated requests?

import http from 'k6/http';
import { check, sleep } from 'k6';
import { Trend, Counter, Rate, Gauge } from 'k6/metrics';

// Custom metrics
var articleLoadTime = new Trend('article_load_time');
var articlesCreated = new Counter('articles_created');
var authSuccessRate = new Rate('auth_success_rate');
var activeArticles = new Gauge('active_articles_count');

export var options = {
  vus: 20,
  duration: '2m',
  thresholds: {
    article_load_time: ['p(95)<250'],    // custom metric threshold
    auth_success_rate: ['rate>0.95'],     // 95% auth success
  },
};

export default function () {
  // Track article load time separately
  var start = Date.now();
  var res = http.get('http://localhost:3000/api/articles');
  articleLoadTime.add(Date.now() - start);

  if (res.status === 200) {
    var body = JSON.parse(res.body);
    activeArticles.add(body.total);
  }

  // Tag requests for per-endpoint analysis
  var authRes = http.post('http://localhost:3000/api/auth/login',
    JSON.stringify({ email: '[email protected]', password: 'testpassword123' }),
    {
      headers: { 'Content-Type': 'application/json' },
      tags: { endpoint: 'login', type: 'auth' },
    }
  );

  authSuccessRate.add(authRes.status === 200);

  if (authRes.status === 200) {
    var token = JSON.parse(authRes.body).token;

    var createRes = http.post('http://localhost:3000/api/articles',
      JSON.stringify({ title: 'Load Test Article ' + __ITER, category: 'testing' }),
      {
        headers: {
          'Content-Type': 'application/json',
          'Authorization': 'Bearer ' + token,
        },
        tags: { endpoint: 'create_article', type: 'write' },
      }
    );

    if (createRes.status === 201) {
      articlesCreated.add(1);
    }
  }

  sleep(1);
}

k6's four custom metric types:

  • Trend -- Tracks a distribution of values (min, max, avg, percentiles). Use for response times.
  • Counter -- Accumulates a total count. Use for "how many X happened."
  • Rate -- Tracks a percentage (true/false). Use for success rates.
  • Gauge -- Tracks a value that goes up and down. Use for queue depth, active connections.

Handling Authentication in Load Tests

JWT Token Authentication

Most Node.js APIs use JWT tokens. The pattern is: authenticate once in setup(), reuse the token across all VUs.

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

export var options = {
  vus: 30,
  duration: '3m',
};

export function setup() {
  var res = http.post('http://localhost:3000/api/auth/login', JSON.stringify({
    email: '[email protected]',
    password: 'testpassword123',
  }), {
    headers: { 'Content-Type': 'application/json' },
  });

  check(res, {
    'login succeeded': function (r) { return r.status === 200; },
  });

  if (res.status !== 200) {
    throw new Error('Authentication failed in setup: ' + res.body);
  }

  return { token: JSON.parse(res.body).token };
}

export default function (data) {
  var headers = {
    'Authorization': 'Bearer ' + data.token,
    'Content-Type': 'application/json',
  };

  var res = http.get('http://localhost:3000/api/articles', { headers: headers });

  check(res, {
    'authenticated 200': function (r) { return r.status === 200; },
    'not 401': function (r) { return r.status !== 401; },
    'not 403': function (r) { return r.status !== 403; },
  });

  sleep(1);
}

Cookie-Based Session Authentication

For applications that use session cookies instead of JWT:

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

export var options = {
  vus: 10,
  duration: '1m',
};

export default function () {
  // k6 automatically manages cookies per VU with its cookie jar
  var loginRes = http.post('http://localhost:3000/login', JSON.stringify({
    email: '[email protected]',
    password: 'testpassword123',
  }), {
    headers: { 'Content-Type': 'application/json' },
  });

  check(loginRes, {
    'login 200': function (r) { return r.status === 200; },
    'session cookie set': function (r) { return r.cookies['connect.sid'] !== undefined; },
  });

  // Subsequent requests automatically include the session cookie
  var res = http.get('http://localhost:3000/api/dashboard');

  check(res, {
    'dashboard accessible': function (r) { return r.status === 200; },
  });
}

k6 maintains a separate cookie jar per VU, so session cookies are isolated between virtual users automatically. No extra configuration needed.


Testing WebSocket Connections

If your Node.js application uses WebSockets (Socket.io, ws, etc.), k6 has native WebSocket support:

import ws from 'k6/ws';
import { check, sleep } from 'k6';
import { Counter } from 'k6/metrics';

var messagesReceived = new Counter('ws_messages_received');
var messagesSent = new Counter('ws_messages_sent');

export var options = {
  vus: 50,
  duration: '2m',
};

export default function () {
  var url = 'ws://localhost:3000/ws';

  var res = ws.connect(url, {}, function (socket) {
    socket.on('open', function () {
      // Send a message every 2 seconds
      socket.setInterval(function () {
        socket.send(JSON.stringify({
          type: 'ping',
          timestamp: Date.now(),
        }));
        messagesSent.add(1);
      }, 2000);
    });

    socket.on('message', function (msg) {
      messagesReceived.add(1);
      var data = JSON.parse(msg);

      check(data, {
        'message has type': function (d) { return d.type !== undefined; },
        'response time < 100ms': function (d) {
          return d.timestamp ? (Date.now() - d.timestamp) < 100 : true;
        },
      });
    });

    socket.on('error', function (e) {
      console.error('WebSocket error:', e.error());
    });

    socket.on('close', function () {
      // Connection closed
    });

    // Keep the connection open for 30 seconds
    socket.setTimeout(function () {
      socket.close();
    }, 30000);
  });

  check(res, {
    'ws status is 101': function (r) { return r && r.status === 101; },
  });

  sleep(1);
}

This test opens 50 concurrent WebSocket connections, sends a message every 2 seconds on each, and validates responses. It is an effective way to test Socket.io or ws-based real-time features under load.


CI/CD Integration

Load tests only matter if they run automatically. Here is how to wire k6 into your CI pipelines.

GitHub Actions

name: Load Test

on:
  push:
    branches: [master]
  pull_request:
    branches: [master]

jobs:
  load-test:
    runs-on: ubuntu-latest

    services:
      app:
        image: node:20
        ports:
          - 3000:3000

    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Install dependencies
        run: npm ci

      - name: Start application
        run: |
          npm start &
          sleep 5

      - name: Install k6
        run: |
          sudo gpg -k
          sudo gpg --no-default-keyring --keyring /usr/share/keyrings/k6-archive-keyring.gpg --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69
          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

      - name: Run load tests
        run: k6 run --out json=results.json tests/load/api-load-test.js

      - name: Upload results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: k6-results
          path: results.json

Azure Pipelines

trigger:
  branches:
    include:
      - master

pool:
  vmImage: 'ubuntu-latest'

steps:
  - task: NodeTool@0
    inputs:
      versionSpec: '20.x'
    displayName: 'Install Node.js'

  - script: npm ci
    displayName: 'Install dependencies'

  - script: |
      npm start &
      sleep 5
    displayName: 'Start application'

  - script: |
      sudo gpg -k
      sudo gpg --no-default-keyring --keyring /usr/share/keyrings/k6-archive-keyring.gpg --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69
      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
    displayName: 'Install k6'

  - script: k6 run --out json=$(Build.ArtifactStagingDirectory)/results.json tests/load/api-load-test.js
    displayName: 'Run load tests'

  - task: PublishBuildArtifacts@1
    condition: always()
    inputs:
      pathToPublish: '$(Build.ArtifactStagingDirectory)/results.json'
      artifactName: 'k6-results'
    displayName: 'Publish results'

Docker-Based CI

If you cannot install k6 natively, use the official Docker image:

docker run --rm --network=host \
  -v $(pwd)/tests:/tests \
  grafana/k6 run /tests/load/api-load-test.js

The --network=host flag is critical -- without it, the k6 container cannot reach localhost:3000 on the host machine.


Analyzing Results and Identifying Bottlenecks

JSON Output

Export results to JSON for post-processing:

k6 run --out json=results.json load-test.js

Each line in the output file is a JSON object representing a metric data point:

{"type":"Point","data":{"time":"2026-02-08T10:15:30.123Z","value":42.5,"tags":{"name":"http://localhost:3000/api/articles","method":"GET","status":"200","endpoint":"articles"}},"metric":"http_req_duration","type":"Point"}

CSV Output

For spreadsheet analysis:

k6 run --out csv=results.csv load-test.js

InfluxDB + Grafana

For real-time dashboards during long-running tests, pipe results to InfluxDB:

k6 run --out influxdb=http://localhost:8086/k6 load-test.js

Then import the k6 Grafana dashboard (dashboard ID 2587) to visualize results in real time. This setup is invaluable for soak tests where you need to watch metrics evolve over hours.

What to Look For

When analyzing k6 results, focus on these signals:

p95 vs p99 divergence -- If your p95 is 50ms but your p99 is 800ms, you have a tail latency problem. This usually means a downstream service (database query, external API) occasionally stalls. In Node.js, this is often a slow MongoDB query or an unoptimized PostgreSQL query missing an index.

Error rate correlation with VU count -- If errors only appear above 100 VUs, you are likely hitting a connection pool limit, file descriptor limit, or event loop saturation. Check your database connection pool size and your OS ulimit -n setting.

Response time increasing linearly with VUs -- This means you have a serialization bottleneck. In Node.js, common culprits are synchronous JSON parsing of large payloads, CPU-intensive template rendering, or a single-connection database client (instead of a pool).

Throughput plateau -- If requests per second stops increasing even as you add more VUs, your application has hit its ceiling. Profile the Node.js process with --prof or use clinic to identify whether it is CPU-bound (event loop), I/O-bound (database), or memory-bound.


Complete Working Example

Here is a comprehensive k6 test suite for the Express API defined earlier. It tests multiple scenarios concurrently, uses custom metrics, sets thresholds, and handles authentication.

import http from 'k6/http';
import { check, group, sleep, fail } from 'k6';
import { Trend, Counter, Rate } from 'k6/metrics';

// ---------------------
// Custom Metrics
// ---------------------
var loginDuration = new Trend('login_duration');
var articleListDuration = new Trend('article_list_duration');
var articleCreateDuration = new Trend('article_create_duration');
var failedLogins = new Counter('failed_logins');
var successRate = new Rate('overall_success_rate');

// ---------------------
// Configuration
// ---------------------
var BASE_URL = __ENV.BASE_URL || 'http://localhost:3000';
var TEST_EMAIL = __ENV.TEST_EMAIL || '[email protected]';
var TEST_PASSWORD = __ENV.TEST_PASSWORD || 'testpassword123';

export var options = {
  scenarios: {
    // Scenario 1: Ramp-up to steady state
    steady_state: {
      executor: 'ramping-vus',
      startVUs: 0,
      stages: [
        { duration: '1m', target: 30 },   // ramp up
        { duration: '3m', target: 30 },   // steady state
        { duration: '1m', target: 0 },    // ramp down
      ],
      exec: 'browseFlow',
      tags: { scenario: 'steady_state' },
    },

    // Scenario 2: Spike test
    spike: {
      executor: 'ramping-vus',
      startVUs: 0,
      startTime: '5m30s',  // start after steady_state finishes
      stages: [
        { duration: '30s', target: 5 },    // baseline
        { duration: '15s', target: 150 },  // spike
        { duration: '1m', target: 150 },   // hold spike
        { duration: '15s', target: 5 },    // drop
        { duration: '1m', target: 5 },     // recovery
        { duration: '30s', target: 0 },    // done
      ],
      exec: 'browseFlow',
      tags: { scenario: 'spike' },
    },

    // Scenario 3: Authenticated write operations at constant rate
    write_operations: {
      executor: 'constant-arrival-rate',
      rate: 10,              // 10 iterations per second
      timeUnit: '1s',
      duration: '5m',
      preAllocatedVUs: 15,
      maxVUs: 50,
      exec: 'writeFlow',
      tags: { scenario: 'writes' },
    },
  },

  thresholds: {
    http_req_duration: ['p(95)<500', 'p(99)<1000'],
    http_req_failed: ['rate<0.05'],
    checks: ['rate>0.95'],
    login_duration: ['p(95)<400'],
    article_list_duration: ['p(95)<300'],
    article_create_duration: ['p(95)<500'],
    overall_success_rate: ['rate>0.95'],
    failed_logins: ['count<10'],
  },
};

// ---------------------
// Setup: Authenticate once
// ---------------------
export function setup() {
  var loginPayload = JSON.stringify({
    email: TEST_EMAIL,
    password: TEST_PASSWORD,
  });

  var res = http.post(BASE_URL + '/api/auth/login', loginPayload, {
    headers: { 'Content-Type': 'application/json' },
  });

  if (res.status !== 200) {
    fail('Setup authentication failed: ' + res.status + ' ' + res.body);
  }

  var token = JSON.parse(res.body).token;
  console.log('Setup complete. Token acquired. Starting load test...');

  return {
    token: token,
    baseUrl: BASE_URL,
  };
}

// ---------------------
// Scenario: Browse Flow (read-heavy)
// ---------------------
export function browseFlow(data) {
  group('01 - Health Check', function () {
    var res = http.get(data.baseUrl + '/api/health', {
      tags: { endpoint: 'health' },
    });
    successRate.add(res.status === 200);
    check(res, {
      'health 200': function (r) { return r.status === 200; },
    });
  });

  group('02 - List All Articles', function () {
    var start = Date.now();
    var res = http.get(data.baseUrl + '/api/articles', {
      tags: { endpoint: 'articles_list' },
    });
    articleListDuration.add(Date.now() - start);
    successRate.add(res.status === 200);

    check(res, {
      'articles 200': function (r) { return r.status === 200; },
      'articles has data': function (r) {
        var body = JSON.parse(r.body);
        return body.articles && body.articles.length > 0;
      },
    });
  });

  group('03 - Filter Articles', function () {
    var categories = ['architecture', 'devops', 'databases'];
    var category = categories[Math.floor(Math.random() * categories.length)];
    var res = http.get(data.baseUrl + '/api/articles?category=' + category, {
      tags: { endpoint: 'articles_filter' },
    });
    successRate.add(res.status === 200);

    check(res, {
      'filtered 200': function (r) { return r.status === 200; },
    });
  });

  group('04 - Get Individual Article', function () {
    var articleId = Math.floor(Math.random() * 3) + 1;
    var res = http.get(data.baseUrl + '/api/articles/' + articleId, {
      tags: { endpoint: 'article_detail' },
    });
    successRate.add(res.status === 200);

    check(res, {
      'article detail 200': function (r) { return r.status === 200; },
      'article has title': function (r) {
        return JSON.parse(r.body).title !== undefined;
      },
    });
  });

  // Simulate user think time between page views
  sleep(Math.random() * 2 + 0.5);
}

// ---------------------
// Scenario: Write Flow (authenticated, write-heavy)
// ---------------------
export function writeFlow(data) {
  group('Auth - Login', function () {
    var start = Date.now();
    var loginRes = http.post(data.baseUrl + '/api/auth/login', JSON.stringify({
      email: TEST_EMAIL,
      password: TEST_PASSWORD,
    }), {
      headers: { 'Content-Type': 'application/json' },
      tags: { endpoint: 'login' },
    });
    loginDuration.add(Date.now() - start);

    var loginOk = check(loginRes, {
      'login 200': function (r) { return r.status === 200; },
      'login has token': function (r) {
        try { return JSON.parse(r.body).token !== undefined; }
        catch (e) { return false; }
      },
    });

    if (!loginOk) {
      failedLogins.add(1);
      successRate.add(false);
      return;
    }

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

    group('Write - Create Article', function () {
      var createStart = Date.now();
      var createRes = http.post(data.baseUrl + '/api/articles', JSON.stringify({
        title: 'Load Test Article VU' + __VU + ' Iter' + __ITER,
        category: 'testing',
      }), {
        headers: {
          'Content-Type': 'application/json',
          'Authorization': 'Bearer ' + token,
        },
        tags: { endpoint: 'create_article' },
      });
      articleCreateDuration.add(Date.now() - createStart);

      successRate.add(createRes.status === 201);
      check(createRes, {
        'create 201': function (r) { return r.status === 201; },
        'created article has id': function (r) {
          try { return JSON.parse(r.body).id !== undefined; }
          catch (e) { return false; }
        },
      });
    });
  });
}

// ---------------------
// Teardown
// ---------------------
export function teardown(data) {
  console.log('Load test complete. Review results above.');
}

Run it with environment variables:

k6 run --env BASE_URL=http://localhost:3000 comprehensive-test.js

Expected output for a healthy API:

scenarios: (100.00%) 3 scenarios, 200 max VUs, 9m30s max duration (incl. graceful stop):
         * steady_state: Up to 30 looping VUs for 5m0s over 3 stages (exec: browseFlow)
         * write_operations: 10.00 iterations/s for 5m0s (maxVUs: 15-50, exec: writeFlow)
         * spike: Up to 150 looping VUs for 3m30s over 6 stages (exec: browseFlow, startTime: 5m30s)

running (09m00.0s), 000/200 VUs, 14523 complete and 0 interrupted iterations

     ✓ health 200
     ✓ articles 200
     ✓ articles has data
     ✓ filtered 200
     ✓ article detail 200
     ✓ article has title
     ✓ login 200
     ✓ login has token
     ✓ create 201
     ✓ created article has id

     ✓ article_create_duration........: avg=18ms   min=4ms   med=14ms   p(95)=42ms   p(99)=87ms
     ✓ article_list_duration..........: avg=8ms    min=1ms   med=6ms    p(95)=22ms   p(99)=45ms
     ✓ checks.........................: 100.00%  ✓ 43569  ✗ 0
     ✓ failed_logins..................: 0        0/s
     ✓ http_req_duration..............: avg=12ms   min=0ms   med=8ms    p(95)=35ms   p(99)=78ms
     ✓ http_req_failed................: 0.00%    ✓ 0      ✗ 29046
     ✓ login_duration.................: avg=15ms   min=3ms   med=12ms   p(95)=38ms   p(99)=72ms
     ✓ overall_success_rate...........: 100.00%  ✓ 29046  ✗ 0

Common Issues and Troubleshooting

1. "dial tcp: lookup localhost: no such host" in Docker

ERRO[0001] GoError: dial tcp: lookup localhost on 127.0.0.11:53: no such host

This happens when you run k6 inside a Docker container and your target URL is localhost. The container's localhost is itself, not the host machine. Fix it with --network=host on Linux, or use host.docker.internal on macOS/Windows:

# Linux
docker run --rm --network=host -v $(pwd):/scripts grafana/k6 run /scripts/test.js

# macOS / Windows
# Change BASE_URL to http://host.docker.internal:3000 in your script
k6 run --env BASE_URL=http://host.docker.internal:3000 test.js

2. "WARN: Insufficient VUs" with constant-arrival-rate

WARN[0045] Insufficient VUs, reached 50 active VUs and cannot initialize more
  executor=constant-arrival-rate scenario=write_operations

This means k6 needs more VUs to maintain your target request rate because responses are slow. Your server is falling behind. Either increase maxVUs in the scenario config, or accept that your application cannot handle the target throughput:

write_operations: {
  executor: 'constant-arrival-rate',
  rate: 10,
  timeUnit: '1s',
  duration: '5m',
  preAllocatedVUs: 20,
  maxVUs: 100,    // increase this
  exec: 'writeFlow',
},

3. "request timeout" errors under moderate load

WARN[0120] Request Failed    error="Get \"http://localhost:3000/api/articles\": request timeout"

k6's default request timeout is 60 seconds. If you are hitting this at modest VU counts (under 100), your application is stalling. Common causes in Node.js:

  • Database connection pool exhausted -- Your pool has 10 connections but 50 VUs are waiting for a query. Increase pool size or add connection timeout handling.
  • Event loop blocked -- A synchronous operation (large JSON.parse, bcrypt hashing, image processing) is blocking the event loop. Use clinic doctor to identify the bottleneck.
  • File descriptor limit -- On Linux, check ulimit -n. Default is often 1024, which is too low for load testing. Set it to at least 65535.

You can also adjust k6's timeout:

var res = http.get('http://localhost:3000/api/articles', {
  timeout: '10s',  // fail fast instead of waiting 60s
});

4. "ERRO: context cancelled" during teardown

ERRO[0305] context cancelled
  at teardown (file:///home/user/test.js:89:15(5))

This occurs when your teardown function takes too long. k6 has a graceful stop period (default 30 seconds) after which it forcefully terminates. If your teardown makes HTTP calls that time out, this error appears. Increase the graceful stop period:

export var options = {
  scenarios: {
    default: {
      executor: 'ramping-vus',
      gracefulStop: '60s',   // give teardown more time
      // ...
    },
  },
};

5. Memory issues with large response bodies

WARN[0180] Memory usage is high, consider reducing VU count or response body size

If your API returns large JSON payloads (pagination responses with thousands of records), k6 stores the entire response body in memory per VU. With 200 VUs each holding a 5MB response, you need 1GB just for response bodies. Solutions:

// Discard response body if you only need status codes
var res = http.get('http://localhost:3000/api/large-dataset', {
  responseType: 'none',  // do not store the body
});

// Or check and discard immediately
var res = http.get('http://localhost:3000/api/large-dataset');
check(res, { 'status 200': function (r) { return r.status === 200; } });
// The body will be garbage collected after this iteration

Best Practices

  • Test against a production-like environment, never production itself. Staging should mirror production's hardware, database size, connection pool config, and network topology. Testing against a dev box with SQLite tells you nothing about your PostgreSQL production database.

  • Always include think time with sleep(). Real users do not fire requests in a tight loop. Without sleep(), you are testing raw throughput, which exaggerates your capacity by 10-50x. Use sleep(Math.random() * 3 + 1) for realistic 1-4 second delays between actions.

  • Use the constant-arrival-rate executor for write endpoints. Unlike ramping-vus, this executor maintains a fixed request rate regardless of response time. When your server slows down under load, the executor spawns more VUs to compensate -- exactly how real traffic behaves. This gives you a true picture of degradation.

  • Set thresholds on p95 and p99, not averages. Averages hide tail latency. A p50 of 10ms with a p99 of 3 seconds means 1% of your users wait 300x longer than the median. Set thresholds on p(95) and p(99) and fail the build when they are breached.

  • Separate read and write scenarios. Read endpoints (GET) and write endpoints (POST/PUT/DELETE) have fundamentally different performance characteristics. Your GET /articles might handle 5000 req/s while your POST /articles tops out at 200 req/s. Test them separately with different VU counts and thresholds.

  • Run load tests in CI on every merge to main. Performance regressions creep in silently -- a new middleware, an unindexed query, a logging change. If you only run load tests quarterly, you will never pinpoint which commit caused the 3x latency increase. Run a quick smoke test (10 VUs, 30 seconds) on every PR and a full load test on every merge.

  • Tag your requests for per-endpoint analysis. Without tags, all HTTP metrics are aggregated together. A slow /api/search endpoint gets averaged with a fast /api/health endpoint, hiding the problem. Use tags: { endpoint: 'search' } on every request and set per-endpoint thresholds.

  • Version control your load test scripts alongside your application code. Load tests should live in a tests/load/ directory in your repo, reviewed in PRs just like application code. When someone changes an endpoint's behavior, they should update the corresponding load test.

  • Warm up your application before measuring. The first 30 seconds of a Node.js application under load include JIT compilation, connection pool initialization, and cache warming. Start with a ramp-up stage and only set thresholds on the steady-state period, not the ramp-up.

  • Monitor your server during load tests, not just k6 output. k6 tells you client-side metrics (latency, errors). You also need server-side metrics: CPU usage, memory, event loop lag, database query times, connection pool utilization. Use clinic, pm2 monit, or Grafana dashboards to correlate server behavior with k6 results.


References

Powered by Contentful