k6 extends what ApacheBench cannot do: simulate realistic user behaviour across multiple URLs, enforce pass/fail thresholds on p95 latency, ramp load up and down gradually, and output structured results for trend analysis. It is written in Go, installs as a single binary, and test scripts are written in JavaScript. This page covers installation, a working WordPress load test script, and how to interpret the output.

Why k6 Instead of ApacheBench for Production Testing

ApacheBench hits a single URL as fast as possible. Real users visit multiple pages, pause between actions (think time), and carry session cookies across requests. A test that ignores think time saturates the server at a concurrency level that no realistic traffic pattern would actually produce. The saturation point measured by ab is the theoretical maximum, not the operational capacity under real-world traffic distribution. k6 simulates virtual users (VUs), each of which runs the test script sequentially and sleeps between requests, just like a browser. A 100-VU test with 3 seconds of think time between page loads generates 100/3 ≈ 33 requests per second — a meaningful comparison against your server’s real-time visitor count. k6 also supports: staged load (ramp from 10 to 200 VUs over 5 minutes, hold, then ramp down), thresholds (fail the test if p95 latency exceeds 2000ms or error rate exceeds 1%), checks (assert on response status code, body content, or headers per request), and structured JSON output for integration with Grafana or CI/CD pipelines. For a WordPress site, a realistic test scenario visits: the home page, a blog post, a category archive, and submits a contact form — matching a real visitor flow and exercising MySQL, Redis, PHP-FPM, and nginx together.

Tools and Commands

# Install k6 on Ubuntu/Debian
gpg -k
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" | \
  tee /etc/apt/sources.list.d/k6.list
apt update && apt install k6

# Verify installation
k6 version

# Run a basic test script
k6 run test.js

# Run with 10 virtual users for 30 seconds
k6 run --vus 10 --duration 30s test.js

# Run with staged load (defined in script options)
k6 run test.js

# Output results as JSON
k6 run --out json=/tmp/k6_results.json test.js

# Quiet mode (suppress progress bar, show only summary)
k6 run --quiet test.js

k6 runs a JavaScript file where each virtual user executes the default exported function in a loop. The options object in the script controls VU count, duration, staged ramps, and thresholds. Command-line –vus and –duration override the script’s options.vus and options.duration.

Key Parameters

Flag / ParameterDescriptionSecurity Note
options.vusNumber of virtual users to run concurrently.Start at 10 VUs with realistic think time. For a WordPress site with 3s sleep between requests, 10 VUs generate ~3 requests/sec. Scale to match your expected peak concurrent visitors, not an arbitrary high number.
options.durationTotal test duration. String format: '30s', '5m', '1h'.Run for at least 5 minutes to capture the server's behaviour after Redis and FastCGI caches warm up. Short tests (< 1 minute) measure cold-start performance, not steady-state.
options.stagesArray of {duration, target} objects defining a load ramp.Ramp up gradually (e.g. 0 to 50 VUs over 2 minutes) to simulate organic traffic growth and let the server stabilise at each load level. A sudden jump to peak VUs tests a denial-of-service scenario, not a normal traffic spike.
options.thresholdsPass/fail criteria evaluated at test completion.Define at minimum: http_req_duration p(95) < 2000 (95th percentile under 2s) and http_req_failed rate < 0.01 (error rate under 1%). k6 exits with code 99 if any threshold is breached — useful in CI/CD pipelines to fail a deployment if performance regresses.
sleep()Pause the virtual user for N seconds between requests.Always include sleep(think_time) between page loads. Think time of 1-5 seconds is realistic for a website. Without sleep, each VU hammers the server as fast as possible — equivalent to ab, not a realistic user session.
check()Assert on response properties and record pass/fail per check.Check response status (200), response body content (a string that should appear), and response time. Checks do not fail the test by default — combine with thresholds on the check failure rate to make them blocking.

Diagnosis and Fix Workflows

Write a realistic WordPress load test script

A WordPress load test that reflects a real visitor session: visits the home page, follows a link to a post, and visits the category page. Includes think time and checks on response status and body content.

// save as: wordpress-test.js
import http from 'k6/http';
import { check, sleep } from 'k6';

export const options = {
  stages: [
    { duration: '2m', target: 20 },   // ramp up to 20 VUs
    { duration: '5m', target: 20 },   // hold at 20 VUs
    { duration: '1m', target: 0 },    // ramp down
  ],
  thresholds: {
    http_req_duration: ['p(95)<2000'],  // 95th pct under 2s
    http_req_failed:   ['rate<0.01'],   // less than 1% errors
  },
};

const BASE = 'https://example.com';

export default function () {
  // Home page
  let res = http.get(`${BASE}/`);
  check(res, {
    'home page 200': (r) => r.status === 200,
    'home page has content': (r) => r.body.includes('example.com'),
  });
  sleep(2);

  // Blog post
  res = http.get(`${BASE}/blog/sample-post/`);
  check(res, {
    'post page 200': (r) => r.status === 200,
  });
  sleep(3);

  // Category archive
  res = http.get(`${BASE}/category/news/`);
  check(res, {
    'category page 200': (r) => r.status === 200,
  });
  sleep(2);
}

// Run:
// k6 run wordpress-test.js

Identify the VU level where server performance degrades

Run a staged ramp test and monitor server metrics simultaneously to find exactly which resource saturates first as load increases.

// save as: ramp-test.js
import http from 'k6/http';
import { sleep } from 'k6';

export const options = {
  stages: [
    { duration: '1m', target: 10 },
    { duration: '1m', target: 25 },
    { duration: '1m', target: 50 },
    { duration: '1m', target: 100 },
    { duration: '1m', target: 150 },
    { duration: '2m', target: 0 },
  ],
  thresholds: {
    http_req_duration: ['p(95)<5000'],
  },
};

export default function () {
  http.get('https://example.com/');
  sleep(1);
}

// Run the test AND monitor server simultaneously:
// Terminal 1: k6 run ramp-test.js
// Terminal 2 (on the server):
watch -n2 'echo "--- PHP-FPM active workers ---" && \
  ps aux | grep php-fpm | grep -v grep | grep -v master | wc -l && \
  echo "--- MySQL connections ---" && \
  mysql -e "SHOW STATUS LIKE \"Threads_connected\";" 2>/dev/null && \
  echo "--- Load average ---" && \
  uptime'

# Correlate the VU ramp stages with when the server metric
# (workers, connections, load) spikes — that is the bottleneck

Test the logged-in WordPress user path

Logged-in WordPress users bypass FastCGI cache and hit PHP and MySQL for every request. This path is typically 50-100x slower and is the real bottleneck for membership sites, WooCommerce, and LMS sites.

// save as: loggedin-test.js
import http from 'k6/http';
import { check, sleep } from 'k6';

export const options = {
  vus: 10,
  duration: '3m',
  thresholds: {
    http_req_duration: ['p(95)<3000'],
    http_req_failed: ['rate<0.02'],
  },
};

const BASE = 'https://example.com';

export default function () {
  // Step 1: Get the login page (retrieves nonce)
  const loginPage = http.get(`${BASE}/wp-login.php`);

  // Step 2: Log in
  const loginRes = http.post(`${BASE}/wp-login.php`, {
    log: 'testuser',
    pwd: 'testpassword',
    wp-submit: 'Log In',
    redirect_to: `${BASE}/wp-admin/`,
    testcookie: '1',
  }, {
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    redirects: 5,
  });

  check(loginRes, {
    'logged in successfully': (r) => r.url.includes('/wp-admin/'),
  });
  sleep(2);

  // Step 3: Access a members-only page (carries session cookie automatically)
  const memberPage = http.get(`${BASE}/members/dashboard/`);
  check(memberPage, {
    'member page 200': (r) => r.status === 200,
  });
  sleep(3);
}

// Run:
// k6 run loggedin-test.js

Performance Impact: Reading the k6 Summary to Find the Real Bottleneck

The k6 end-of-test summary prints metrics for every HTTP request made during the test. The most diagnostic values: http_req_duration (the time from sending request to receiving the last byte), http_req_waiting (time waiting for the first byte — the server processing time), and http_req_connecting (TCP connection time — should be near zero with Keep-Alive). When http_req_waiting is high relative to http_req_duration, the server is slow to start responding — this is PHP, MySQL, or Redis processing time. When http_req_connecting is high, connections are not being reused — check if Keep-Alive is enabled on the server. When the check failure rate rises as VU count increases (in staged tests), the server is returning errors under load — check nginx and PHP-FPM error logs for the specific error type. The iteration_duration metric includes the sleep() time and shows the true simulated user session length. At 100 VUs with 3s sleep and 5s of page loads per iteration, iteration_duration should be approximately 8 seconds. If it is much higher, requests are queueing and VUs are spending extra time waiting. The http_reqs counter divided by test duration gives the actual requests per second sustained throughout the test — compare this against the theoretical VU/sleep calculation to quantify queueing.

  • k6 generates real HTTP traffic to a real server — never run a load test against a production server without coordinating with all stakeholders and ensuring monitoring is in place to detect and respond to unintended outages.
  • Logged-in test scripts with real credentials embedded in the file should never be committed to version control — use k6 environment variables (–env USER=x –env PASS=y) to pass credentials at runtime.
  • k6's default connection pool reuses TCP connections across VUs, which reduces TCP overhead and produces optimistic throughput numbers compared to cold-connection clients — ensure -k behaviour matches your application's actual client behaviour.
  • Cloud-based k6 (k6 Cloud / Grafana Cloud) sends traffic from external IPs that may be blocked by firewalls or rate limiters — test from the same data centre or a known IP when measuring internal server performance.

Practical Examples

Parse k6 JSON output to extract p95 latency trend

# Run k6 with JSON output
k6 run --out json=/tmp/k6_results.ndjson wordpress-test.js

# Extract p95 http_req_duration data points over time (requires jq)
jq -r 'select(.metric == "http_req_duration" and .type == "Point") | [.data.time, .data.value] | @csv' \
  /tmp/k6_results.ndjson | head -20

# Get the final summary metrics as a clean JSON object
k6 run --summary-export=/tmp/k6_summary.json wordpress-test.js
jq '.metrics.http_req_duration' /tmp/k6_summary.json
# Output:
# {
#   "avg": 342.5,
#   "min": 89.2,
#   "med": 287.1,
#   "max": 4821.3,
#   "p(90)": 612.4,
#   "p(95)": 891.2
# }

The summary export JSON is useful for scripted comparisons between deployments — subtract the p95 value before a change from p95 after a change to quantify the regression or improvement with a single number.

Test with randomised URLs to bypass FastCGI cache

// Append a random query string to force PHP execution on every request
// (bypasses nginx FastCGI cache, which requires no query string by default)
import http from 'k6/http';
import { sleep } from 'k6';

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

export default function () {
  // Cache-bypass version: measures pure PHP execution throughput
  const bypass = `https://example.com/?nocache=${Math.random()}`;
  http.get(bypass);
  sleep(1);
}

// Compare throughput of this test vs a cached test to quantify
// FastCGI cache effectiveness:
// Cached: 2000 req/s
// Uncached: 25 req/s
// Cache effectiveness: (2000-25)/25 = 79x throughput improvement

Testing with cache bypass measures your server’s PHP execution capacity — the floor beneath your caching infrastructure. It reveals how many concurrent dynamic (logged-in, uncached) requests your PHP-FPM and MySQL stack can handle.

Troubleshooting Common Issues

Problem: k6 test exits immediately with 'No script' or syntax errors

Solution: k6 uses a JavaScript ES6 subset, not Node.js. require() is not available — use import/export syntax. The default export must be a function. If the script was written for Node.js or uses CommonJS modules, rewrite the imports: import http from ‘k6/http’; import { sleep } from ‘k6’; are the standard k6 imports.

Problem: k6 shows high check failure rate but curl to the same URL returns 200

Solution: k6 VUs may be reaching the server faster than it can issue sessions or nonces. Check whether the failures are specific to the first request (before login) or after login. Also verify the check condition — if checking for a string in the body, ensure the string exists in the actual HTML and is not added by JavaScript after page load (k6 does not execute JavaScript; it receives only the raw HTML response).

Problem: k6 exit code 99 even when most requests succeed

Solution: Exit code 99 means at least one threshold was breached. Check which threshold failed in the final summary output — look for lines marked ‘X’ (failed) vs ‘v’ (passed). The error rate threshold (http_req_failed rate < 0.01) is the most common breach. A single failed request in a 100-request test is a 1% error rate, which breaches a 1% threshold. Adjust the threshold to match realistic tolerance or investigate the cause of the failed requests.

Summary

Install k6 from the official apt repository. Write a test script that simulates realistic user behaviour across multiple URLs with sleep() between requests. Set thresholds on p95 latency and error rate to get a pass/fail result. Use staged ramp scenarios to find the VU count where performance degrades. Run the logged-in user path separately — it bypasses FastCGI cache and reveals PHP and MySQL capacity independently.

  • Always include sleep() between page requests in k6 scripts — without it, each VU hammers the server at maximum speed, which is an unrealistic denial-of-service test rather than a load test.
  • Set thresholds: { http_req_duration: ['p(95)<2000'] } and k6 will exit with a non-zero code if 95th percentile latency exceeds 2 seconds — making load test results usable as CI/CD pass/fail gates.
  • Use staged ramps (options.stages) and monitor server resources concurrently to identify exactly which resource (PHP-FPM workers, MySQL connections, CPU) saturates first as load increases.

Is Your Server Running at Full Performance?

INTRAM manages Linux servers with performance tuning built in from day one — correct MySQL configuration, PHP stack selection, nginx or Apache optimisation, and continuous monitoring so slowdowns are caught before users notice.

Explore Managed Hosting

Let’s assess what your business actually needs.

We will use these details only to understand your request and reply appropriately.