Load Testing a WooCommerce Site: A K6 Case Study
Website Errors

Load Testing a WooCommerce Site: A K6 Case Study

Nikhil Gautam ยท Apr 1, 2026 ยท 3 min read

In Part 1 we fixed the critical migration bugs. The database was repaired, memory limits raised, and plugin conflicts cleared. But the site was still hitting 15โ€“20 second load times under any real traffic. Before optimising anything, we needed to measure objectively. This is how we set up load testing with K6.

Why load testing matters

You can’t optimise what you can’t measure. Without objective data, you’re guessing. Here’s what load testing reveals that standard tools miss:

What people rely on (wrong)

  • “It feels slow” โ€” not actionable
  • PageSpeed Insights โ€” single user, empty cache
  • “Works fine for me” โ€” your cache isn’t real users
  • “We added caching” โ€” did you verify it?
What load testing reveals

  • Performance under concurrent users
  • Where bottlenecks appear under load
  • Which pages break first under pressure
  • Before/after proof that optimisations work

💡

Real finding: Our client’s site loaded in 3 seconds for a single user. With 10 concurrent users, the average response time jumped to 20+ seconds. Without load testing, we never would have discovered this โ€” and we would have optimised the wrong things.

Why we chose K6

We evaluated several tools โ€” Apache JMeter, Locust, Artillery, Loader.io โ€” and chose Grafana K6 for WooCommerce testing:

  • Open source and free to use
  • JavaScript-based โ€” easy to write realistic test scenarios
  • Detailed metrics โ€” response times, error rates, throughput
  • Flexible โ€” test specific pages, user flows, or API endpoints
  • Run locally or in the cloud

Installing K6

# Mac
brew install k6

# Ubuntu/Debian
sudo apt-get install k6

# Windows
choco install k6

# Verify
k6 version

Our testing strategy

We created four test scenarios and ran each at increasing concurrent user levels: 5, 10, 20, and 40 users.

Test 1 โ€” Homepage
Hero slider, featured products, navigation load.
Test 2 โ€” Shop with filters
Most resource-intensive. Critical for e-commerce UX.
Test 3 โ€” Product page
Individual product rendering, variations, related items.
Test 4 โ€” Full user journey
Homepage โ†’ Shop โ†’ Filter โ†’ Product โ†’ Add to Cart.

Important note on virtual users: In K6, 1 virtual user โ‰ˆ 20โ€“30 real users because real users have think time, don’t all click simultaneously, and don’t make continuous requests. So 10 virtual users โ‰ˆ 200โ€“300 real visitors browsing concurrently.

Test 1: Homepage load test

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

export const options = {
  stages: [
    { duration: '2m', target: 5 },   // Ramp to 5 users
    { duration: '2m', target: 10 },  // Ramp to 10 users
    { duration: '2m', target: 20 },  // Ramp to 20 users
    { duration: '2m', target: 40 },  // Ramp to 40 users
    { duration: '2m', target: 0 },   // Cool down
  ],
  thresholds: {
    http_req_duration: ['p(95)<5000'], // 95% under 5s
    http_req_failed: ['rate<0.10'],    // Under 10% errors
  },
};

const BASE_URL = 'https://yoursite.com';

export default function () {
  const res = http.get(`${BASE_URL}/`, {
    headers: { 'Cache-Control': 'no-cache' },
    timeout: '30s',
  });

  check(res, {
    'status is 200': (r) => r.status === 200,
    'response time < 5s': (r) => r.timings.duration < 5000,
    'body returned': (r) => r.body.length > 0,
  });

  sleep(1);
}

Test 2: Shop page with filters

The shop page with product filtering was the most critical test. We randomised the filter URLs to simulate real browsing behaviour:

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

const BASE_URL = 'https://yoursite.com';

const FILTER_URLS = [
  '/shop/?orderby=popularity',
  '/shop/?orderby=popularity&filter_color=black',
  '/shop/?orderby=popularity&filter_finish=matte',
  '/shop/?orderby=popularity&filter_capacity=20-oz',
];

function randomItem(arr) {
  return arr[Math.floor(Math.random() * arr.length)];
}

export default function () {
  const url = `${BASE_URL}${randomItem(FILTER_URLS)}`;

  const res = http.get(url, {
    timeout: '30s',
    tags: { page_type: 'shop_filter' },
  });

  check(res, {
    'status is 200': (r) => r.status === 200,
    'response < 5s': (r) => r.timings.duration < 5000,
    'has products': (r) => r.body.includes('product'),
  });

  sleep(2); // Users browse shop pages longer
}

Initial test results: the disaster

Our first complete test run revealed shocking numbers:

THRESHOLDS
  โœ— http_req_duration: p(95)=23.72s  (threshold: p(95) < 5s)

RESULTS
  checks_succeeded: 76.64%  โœ“ 426   โœ— 130
  checks_failed:    23.27%

HTTP METRICS
  http_req_duration: avg=7.67s  med=5.2s  max=30s  p(95)=23.72s
  http_req_failed:   11.65%

Over 23% of checks failed. The 95th percentile response time was nearly 24 seconds โ€” nearly 5x our threshold. This confirmed that the issues were far deeper than we initially suspected and measurement-driven optimisation was the only path forward.

Up next in this series

Part 3: Finding the Bottleneck

Database query analysis, slow query logging, and identifying the exact queries that were killing performance under load.

Read Part 3 โ†’

Need a performance audit?

We’ll load test your site and show you exactly what’s slow.

Objective data. Real concurrent users. No guessing.

Get a Free Performance Audit

Written by Nikhil Gautam

Submit a Comment