5-Part Case Study Series
✅ Part 2: Load Testing with K6 (You are here)
Part 3: Finding the Bottleneck
Part 4: The Optimisation Playbook
Part 5: Results & Lessons Learned
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:
- “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?
- 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.
Hero slider, featured products, navigation load.
Most resource-intensive. Critical for e-commerce UX.
Individual product rendering, variations, related items.
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.
Need a performance audit?
We’ll load test your site and show you exactly what’s slow.
Objective data. Real concurrent users. No guessing.
