Introduction
In Part 1 of this series, we fixed critical migration bugs that were preventing our client’s WooCommerce site from functioning properly. The database corruption was resolved, memory limits were increased, and all plugin conflicts were eliminated.
But the site was still painfully slow – 15-20+ seconds to load pages, with frequent timeouts under even moderate traffic.
Before we could optimise performance, we needed to measure it objectively. This is where load testing comes in.
In this post, we’ll cover:
- Why load testing is essential before optimisation
- Setting up Grafana K6 for WooCommerce testing
- Creating realistic test scenarios
- Running tests with 5, 10, 20, and 40 concurrent users
- The “double-slash bug” that skewed our initial results
- Shocking performance metrics that revealed the true problem
Why Load Testing Matters
You can’t optimise what you can’t measure. Without objective data, you’re just guessing.
Common mistakes we see:
- “It feels slow” – Subjective, not actionable
- “PageSpeed Insights says…” – Tests empty cache, single user, doesn’t reflect real usage
- “Works fine for me” – Your local network and empty browser cache don’t represent real users
- “We added caching, should be better” – Did you verify? By how much?
What load testing reveals:
- How your site performs under concurrent users (not just one)
- Where bottlenecks appear as load increases
- Which pages/features break first under pressure
- Objective metrics you can track over time
- Before/after comparisons to prove optimisations work
Real-world example:
Our client’s site loaded in 3 seconds for a single user, but with 10 concurrent users, the average response time jumped to 20+ seconds. Without load testing, we never would have discovered this.
Choosing a Load Testing Tool: Why K6?
We chose Grafana K6 for several reasons:
Why K6:
- ✅ Open source – Free to use
- ✅ JavaScript-based – Easy to write test scenarios
- ✅ Realistic load – Simulates real browser behaviour
- ✅ Detailed metrics – Response times, error rates, throughput
- ✅ Flexible – Can test specific pages, user flows, API endpoints
- ✅ Cloud or local – Run on your machine or cloud servers
Alternatives we considered:
- Apache JMeter – More complex, GUI-based, heavier resource usage
- Locust – Python-based, good for API testing
- Artillery – Similar to K6, but with less detailed metrics
- Loader.io – Cloud-based, costs money for serious testing
Setting Up K6
Installation
K6 installation is straightforward:
On Mac:
[bash]
brew install k6
On Ubuntu/Debian:
[bash]
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
[bash]
choco install k6
[bash]
k6 version
You should see output like:
Our Testing Strategy
We needed to test the site under realistic conditions that mimic actual user behavior.
Test Scenarios We Created
1. Homepage Load Test
- Simulates users landing on the homepage
- Test the hero slider, featured products, and navigation
2. Shop Page with Filters
- Most resource-intensive page
- Test product queries with multiple filters
- Critical for e-commerce user experience
3. Single Product Page
- Tests individual product rendering
- Includes images, variations, and related products
4. Complete User Journey
- Homepage → Shop → Filter → Product → Add to Cart
- Most realistic scenario
Load Levels
We tested at increasing concurrent user levels:
- 5 users – Baseline, light traffic
- 10 users – Moderate traffic, typical for small stores
- 20 users – Heavy traffic, peak hours
- 40 users – Stress test, Black Friday / flash sale levels
Why these numbers?
In load testing, 1 virtual user ≈ 20-30 real users because:
- Real users have think time (reading, browsing)
- Real users don’t all click at the exact same moment
- K6 virtual users make continuous requests without pausing
So, 10 virtual users ≈ 200-300 real users are browsing your site.
Creating Our First K6 Test
Test 1: Homepage Load Test
Here’s our first K6 script to test the homepage:
[JavaScript]
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
stages: [
{ duration: '2m', target: 5 }, // Ramp up to 5 users
{ duration: '2m', target: 10 }, // Ramp up to 10 users
{ duration: '2m', target: 20 }, // Ramp up to 20 users
{ duration: '2m', target: 40 }, // Ramp up to 40 users
{ duration: '2m', target: 0 }, // Ramp down to 0
],
thresholds: {
http_req_duration: ['p(95)<5000'], // 95% of requests under 5s
http_req_failed: ['rate<0.10'], // Less than 10% errors }, }; let BASE_URL = 'https://newstage.adnart.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); // Think time between requests
}
Key Elements Explained:
Stages – Gradual ramp-up of users:
- Start with 5 users for 2 minutes
- Increase to 10 users for 2 minutes
- Continue ramping to 20, then 40 users
- Ramp down to 0 (cool-down period)
Thresholds – Pass/fail criteria:
p(95)<5000– 95% of requests must complete in under 5 secondsrate<0.10– Error rate must be under 10%
Checks – Validate each response:
- Status code is 200 (success)
- Response time is under 5 seconds
- Body content was returned
Sleep – Simulates user “think time” between actions
Running the First Test
Save the script as and run:
[bash]
k6 run homepage-test.js
```
**What you'll see:**
K6 outputs real-time metrics:
```
✓ status is 200
✓ response time < 5s
✓ body returned
checks.........................: 100.00% ✓ 180 ✗ 0
data_received..................: 30 MB 500 kB/s
data_sent......................: 15 kB 250 B/s
http_req_duration..............: avg=3.2s min=1.1s med=2.9s max=8.5s p(90)=5.2s p(95)=6.1s
http_req_failed................: 0.00% ✓ 0 ✗ 60
http_reqs......................: 60 1/s
iterations.....................: 60 1/s
vus............................: 40 min=5 max=40
Test 2: Shop Page with Filters
The shop page with product filtering was the most critical to test:
[JavaScript]
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
stages: [
{ duration: '30s', target: 10 },
{ duration: '1m', target: 10 },
{ duration: '30s', target: 0 },
],
thresholds: {
http_req_duration: ['p(95)<5000'], }, }; let BASE_URL = 'https://newstage.adnart.com'; const FILTER_URLS = [ '/shop/?orderby=popularity', '/shop/?orderby=popularity&filter_color-catgories=black', '/shop/?orderby=popularity&filter_finish=matte-finish', '/shop/?orderby=popularity&filter_capacity=20-oz-25-oz', '/shop/?orderby=popularity&filter_color-catgories=black&filter_finish=soft-touch', ]; 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 spend more time on shop page
}
What’s Different:
- Shorter test – 30s ramp up, 1 minute sustained, 30s ramp down
- Random URLs – Simulates users applying different filters
- Tags – Label metrics by page type for better analysis
- Longer sleep – 2 seconds to simulate browsing behavior
- Custom check – Verifies products are actually rendered
Running the Shop Filter Test
[bash]
k6 run shop-filter-test.js
```
---
## Initial Test Results: The Disaster
Our first complete test run revealed shocking metrics:
```
THRESHOLDS
✗ http_req_duration..............: p(95)=23.72s
RESULTS
checks_total...................: 556
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% ✓ 130 ✗ 985
http_reqs......................: 1,115 18.5/s
ERRORS
timeouts.......................: 130 requests timed out
error_rate.....................: 23.27%
```
**Translation:**
* ❌ **23% error rate** - Nearly 1 in 4 requests failed
* ❌ **23.72 seconds** - 95th percentile response time
* ❌ **130 timeouts** - Requests that took over 30 seconds
* ❌ **11.65% HTTP failures** - Server errors, connection issues
**This was worse than we expected.**
---
## The Double-Slash Bug Discovery
While analysing the failed requests, we noticed something odd in the error logs:
```
WARN: Get "https://newstage.adnart.com//shop/?orderby=popularity": 301 redirect
WARN: Get "https://newstage.adnart.com//product-category/barware/": 301 redirect
Notice the double slash () after the domain?
What Went Wrong
Our K6 scripts had:
[JavaScript]
let BASE_URL = 'https://newstage.adnart.com/'; // Trailing slash!
// Later in code:
const url = `${BASE_URL}/shop/`; // Leading slash!
// Result: https://newstage.adnart.com//shop/
This caused:
- 301 redirect on every request (doubled the server load)
- Skewed metrics – We were measuring redirect time + page load time
- Extra database queries – WordPress processed each URL twice
The Fix
Simple but critical:
[javascript]
// Remove trailing slash from BASE_URL
let BASE_URL = 'https://newstage.adnart.com';
BASE_URL = BASE_URL.replace(/\/$/, ''); // Safety check
// Now URLs are correct:
// BASE_URL + '/shop/' = 'https://newstage.adnart.com/shop/' ✅
```
**Lesson learned:** Always validate your test URLs before running large-scale tests!
---
## Re-Testing After the Fix
We fixed the double-slash bug and re-ran the tests:
```
RESULTS (After URL Fix)
http_req_duration..............: avg=11.29s p(95)=30s
http_req_failed................: 5.93%
error_rate.....................: 5.93%
timeouts.......................: 50 (down from 130)
Better, but still terrible:
- ✅ Error rate dropped from 23% to 6%
- ✅ Timeouts reduced by 60%
- ❌ Still averaging 11+ seconds per request
- ❌ 95th percentile still hitting 30-second timeout
The site was functional, but completely unusable under load.
What the Tests Revealed
Key Findings from Load Testing
1. Single User vs. Concurrent Users
- 1 user: 3-4 seconds (acceptable)
- 10 users: 11 seconds average (terrible)
- 40 users: 20+ seconds, frequent timeouts (broken)
Performance degradation was exponential, not linear.
2. Shop Filters Were the Killer
- Homepage: 4-6 seconds under load
- Simple product page: 5-8 seconds
- Shop with 2+ filters: 20-30 seconds ← The problem
3. Database Was the Bottleneck
Monitoring server resources during tests:
[bash]
# CPU usage: 30-40% (plenty of headroom)
# RAM usage: 8GB / 16GB (50%, not the issue)
# Disk I/O: Normal
# Database connections: MAXED OUT at 150
4. Error Patterns
Most errors occurred:
- During filter queries with 3+ filters
- When multiple users hit the shop page simultaneously
- After 2-3 minutes of sustained load (connection pool exhaustion)
Test Metrics Explained
Understanding K6 Output
http_req_duration – Total request time:
avg– Average time across all requestsmed– Median (50th percentile)p(90)– 90th percentile (90% of requests faster than this)p(95)– 95th percentile (our threshold)
http_req_failed – Percentage of failed requests:
- HTTP errors (500, 502, 503, 504)
- Timeouts
- Connection failures
iterations – Complete test cycles:
- How many times the script ran completely
- Higher is better (means requests completed)
vus – Virtual users:
- Current number of simulated users
minandmaxshow the range during test
Creating a Monitoring Dashboard
To track metrics over time, we created a simple monitoring script:
[bash]
#!/bin/bash
# monitor-during-test.sh
echo "Starting monitoring..."
while true; do
echo "=== $(date) ==="
# Server load
uptime
# Database connections
mysql -e "SHOW STATUS LIKE 'Threads_connected';"
# PHP-FPM status
ps aux | grep php-fpm | wc -l
# Memory usage
free -h | grep Mem
echo "---"
sleep 10
done
What’s Next?
We now had objective data proving the site was broken under load. The numbers didn’t lie:
- 23-second response times at p95
- 23% error rate
- Exponential degradation with concurrent users
- Database connection pool exhaustion
In Part 3 of this series, we’ll use this data to identify the exact bottlenecks:
- Installing Query Monitor to analyse database queries
- Discovering the “4x meta join” problem
- Finding 194 duplicate queries per page load
- Identifying missing database indexes
- The WPML translation query nightmare
With measurement complete, the real detective work begins.
👉 Continue to Part 3: Finding the Bottleneck
About This Series
This is Part 2 of our 5-part case study on WooCommerce performance optimisation:
- Part 1: The Performance Crisis
- ✅ Part 2: Load Testing Strategy (You just read this)
- Part 3: Finding the Bottleneck
- Part 4: The Optimisation Playbook
- Part 5: Results & Lessons Learned
Have you load tested your WooCommerce site? What tools do you use? Share your experience in the comments!









