In Part 2 of this series, we set up comprehensive load testing with Grafana K6 and discovered shocking performance metrics:
- 23-second response times at 95th percentile
- 23% error rate under moderate load
- Exponential degradation with concurrent users
- Database connection pool maxing out at 150 connections
We had objective proof the site was broken, but we still didn’t know why. The server had plenty of CPU and RAM headroom, so the bottleneck wasn’t hardware.
In this post, we’ll cover:
- Installing and using Query Monitor to analyse database queries
- Discovering the “4x meta join” catastrophe
- Finding 194 duplicate queries per page load
- Identifying missing database indexes
- The WPML translation query nightmare
- Server resource monitoring during load tests
This is where the real detective work begins.
Installing Query Monitor
Query Monitor is a WordPress plugin that shows you exactly what queries run on each page load, how long they take, and where they’re called from.
Installation
[Bash]
# Install via WP-CLI
wp plugin install query-monitor --activate
# Or install manually from WordPress dashboard:
# Plugins → Add New → Search "Query Monitor"
```
**No configuration needed** - it just works. An admin toolbar menu appears showing database stats.
### What Query Monitor Shows
Once activated, you'll see in the admin bar:
* **Database queries:** 487 queries in 8.23s
* **PHP errors:** 12 warnings
* **HTTP requests:** 3 external API calls
Click on it to see a detailed breakdown of:
* Every SQL query executed
* Execution time for each query
* Where in the code it was called
* Duplicate queries
* Slow queries (over 0.05s)
**This is gold for performance debugging.**
---
## First Look: The Numbers Were Shocking
We loaded the shop page with 2 filters active and checked Query Monitor:
```
Total Queries: 487
Total Query Time: 8.23 seconds
Slowest Query: 4.82 seconds
Duplicate Queries: 236
Let that sink in:
- 487 database queries for a single page load
- 8.23 seconds just in database time (not counting PHP, rendering, etc.)
- 236 duplicate queries – the same query executed multiple times
For comparison: A well-optimised WooCommerce site should have:
- 50-100 queries per page
- Under 1 second total query time
- Less than 10 duplicates
We were 8x over the normal query count and 8x over normal query time.
Query Monitor highlighted the slowest query – it was taking 4.82 seconds and running on every product page:
[SQL]
SELECT SQL_CALC_FOUND_ROWS wp_posts.ID
FROM wp_posts
LEFT JOIN wp_postmeta ON (wp_posts.ID = wp_postmeta.post_id)
LEFT JOIN wp_postmeta AS mt1 ON (wp_posts.ID = mt1.post_id)
LEFT JOIN wp_postmeta AS mt2 ON (wp_posts.ID = mt2.post_id)
LEFT JOIN wp_postmeta AS mt3 ON (wp_posts.ID = mt3.post_id)
JOIN wp_icl_translations wpml_translations
ON wp_posts.ID = wpml_translations.element_id
AND wpml_translations.element_type = CONCAT('post_', wp_posts.post_type)
WHERE wp_postmeta.meta_key = '_wc_gpo_custom_order'
AND mt1.meta_key = '_wc_gpo_custom_order'
AND mt2.meta_key = '_wc_gpo_custom_order'
AND mt3.meta_key = '_wc_gpo_custom_order'
AND wp_posts.post_type = 'product'
AND wp_posts.post_status = 'publish'
AND wpml_translations.language_code = 'en'
GROUP BY wp_posts.ID
ORDER BY CAST(COALESCE(wp_postmeta.meta_value, '0') AS SIGNED) DESC
LIMIT 0, 24;
```
### What's Wrong with This Query?
**Problem 1: Multiple JOINs on the Same Table**
The query joins `wp_postmeta` **four times** (wp_postmeta, mt1, mt2, mt3). Each JOIN multiplies the number of rows being compared.
**The math:**
* wp_posts: ~1,000 products
* wp_postmeta: ~3,000 rows per product (WooCommerce stores everything as meta)
* 4 JOINs = 1,000 × 3,000 × 3,000 × 3,000 × 3,000 = **81 trillion row comparisons**
**This is computational insanity.**
**Problem 2: SQL_CALC_FOUND_ROWS**
This forces MySQL to calculate the total number of matching rows even though we're only displaying 24 products. It prevents MySQL from using optimizations and indexes effectively.
**Problem 3: No Indexes**
The query searches for `meta_key = '_wc_gpo_custom_order'` but there's no index on the `meta_key` column, forcing a full table scan.
**Problem 4: WPML Translation Join**
Every product query has to join through WPML's translation table, adding another layer of complexity.
### Where Did This Come From?
Query Monitor showed the **call stack:**
```
WC_Global_Product_Order::pre_get_posts()
→ wp-content/plugins/woocommerce-global-product-order/includes/class-wc-global-product-order.php:247
The culprit: WC Global Product Order plugin.
This plugin allows you to manually order products, but it does so by joining the postmeta table 4 times on every product query. For 1,000 products, this is a performance killer.
Finding Duplicate Queries
Query Monitor has a “Duplicate Queries” tab that groups identical queries together.
The Top Offenders
1. User Meta Queries – 194 duplicates
[SQL]
SELECT meta_key, meta_value
FROM wp_usermeta
WHERE user_id = 1
This exact query ran 194 times on a single page load!
Why?
WordPress and WooCommerce individually load user meta for every function that needs it, instead of loading it once and caching it.
2. WPML String Translation – 42 duplicates
[SQL]
SELECT context, name, value
FROM wp_icl_strings
WHERE language = 'en'
AND context = 'woocommerce'
42 times for translating WooCommerce strings like “Add to cart”, “Price”, “Sale”, etc.
Why?
WPML loads translations for every string individually instead of batch-loading all strings at once.
3. Product Meta Lookups – 38 duplicates
[SQL]
SELECT post_id, meta_key, meta_value
FROM wp_postmeta
WHERE post_id = 123
AND meta_key = '_price'
38 times looking up the same product’s price!
Why?
Different plugins and theme functions all independently check product prices without checking if it’s already been loaded.
The Impact of Duplicates
Math time:
- Average query time: 0.015 seconds
- 236 duplicate queries × 0.015s = 3.54 seconds wasted
- That’s 43% of our total database time!
Eliminating duplicates alone could cut database time nearly in half.
Missing Database Indexes
Query Monitor also highlights queries that would benefit from indexes.
What Are Database Indexes?
Think of a database index like an index in a book:
Without an index: To find all mentions of “performance” in a 500-page book, you’d have to read every single page.
With an index: Jump directly to pages 45, 127, 203, 389 where “performance” appears.
Same concept in databases.
Critical Missing Indexes
We ran this query to check existing indexes:
[SQL]
SHOW INDEX FROM wp_postmeta;
Result: Only 1 index (on ).
Missing indexes we needed:
[SQL]
-- Index on meta_key (most queried column)
ALTER TABLE wp_postmeta
ADD INDEX idx_meta_key (meta_key(191));
-- Composite index on post_id + meta_key (common query pattern)
ALTER TABLE wp_postmeta
ADD INDEX idx_post_meta (post_id, meta_key(191));
-- Index on meta_value (for sorting by price, etc.)
ALTER TABLE wp_postmeta
ADD INDEX idx_meta_value (meta_key(191), meta_value(50));
Why 191 characters?
MySQL has a limit on index key length. For UTF-8 character sets:
- 191 characters × 4 bytes = 764 bytes (under the 767-byte limit)
- 192 characters would exceed the limit and fail
Checking WPML Translation Indexes
[SQL]
SHOW INDEX FROM wp_icl_translations;
Result: Only 1 index (on primary key).
Missing indexes we needed:
[SQL]
-- Index on element_id (product ID)
ALTER TABLE wp_icl_translations
ADD INDEX idx_element_id (element_id);
-- Composite index on element + language
ALTER TABLE wp_icl_translations
ADD INDEX idx_element_language (element_id, element_type(50), language_code(10));
-- Index on translation relationship
ALTER TABLE wp_icl_translations
ADD INDEX idx_trid_language (trid, language_code(10));
The Impact of Missing Indexes
We added just the index to test:
[SQL]
ALTER TABLE wp_postmeta ADD INDEX idx_meta_key (meta_key(191));
Before: Query took 4.82 seconds
After: Same query took 1.23 seconds
75% improvement from ONE index!
The WPML Translation Problem
WPML (WordPress Multilingual Plugin) adds translation support but comes with a serious performance cost.
How WPML Works
Every database query for posts, products, or pages gets wrapped in WPML translation logic:
Original WooCommerce query:
[SQL]
SELECT ID, post_title, post_content
FROM wp_posts
WHERE post_type = 'product'
AND post_status = 'publish'
LIMIT 24;
What WPML transforms it into:
[SQL]
SELECT wp_posts.ID, wp_posts.post_title, wp_posts.post_content
FROM wp_posts
JOIN wp_icl_translations t
ON wp_posts.ID = t.element_id
AND t.element_type = 'post_product'
WHERE wp_posts.post_type = 'product'
AND wp_posts.post_status = 'publish'
AND t.language_code = 'en'
AND t.element_id IN (
SELECT element_id FROM wp_icl_translations
WHERE trid = (
SELECT trid FROM wp_icl_translations
WHERE element_id = wp_posts.ID
AND element_type = 'post_product'
)
AND language_code = 'en'
)
LIMIT 24;Notice the problem?
- Added 2 JOINs and 2 subqueries
- Query went from simple to complex
- For 24 products × 1,000 products in catalog = massive overhead
WPML Query Count
On a single shop page:
- Without WPML: 87 queries
- With WPML: 312 queries
WPML added 225 queries just for translation support!
The String Translation Nightmare
WPML also translates every string (“Add to cart”, “Price”, “Out of stock”, etc.):
[SQL]
SELECT context, name, value, domain_name_context_md5
FROM wp_icl_strings s
LEFT JOIN wp_icl_string_translations st
ON s.id = st.string_id
AND st.language = 'en'
WHERE s.context = 'woocommerce'
AND s.name = 'add_to_cart_text'This query runs for EVERY translatable string.
For a WooCommerce shop page:
- ~150 unique strings to translate
- Each one triggers a separate query
- 150 additional database queries
Server Resource Monitoring
While analysing queries, we also monitored server resources during load tests to correlate database queries with system impact.
Monitoring Script
[BASH]
#!/bin/bash
# monitor-server.sh - Run during load tests
echo "Starting server monitoring..."
echo "Timestamp, Load, CPU%, Memory_Used, DB_Connections, PHP_Processes" > metrics.csv
while true; do
TIMESTAMP=$(date +%s)
LOAD=$(uptime | awk -F'load average:' '{print $2}' | cut -d, -f1 | xargs)
CPU=$(top -bn1 | grep "Cpu(s)" | awk '{print $2}' | cut -d% -f1)
MEM=$(free | grep Mem | awk '{printf("%.1f"), $3/$2 * 100.0}')
DB_CONN=$(mysql -e "SHOW STATUS LIKE 'Threads_connected';" | tail -1 | awk '{print $2}')
PHP_PROC=$(ps aux | grep php-fpm | grep -v grep | wc -l)
echo "$TIMESTAMP, $LOAD, $CPU, $MEM, $DB_CONN, $PHP_PROC" >> metrics.csv
sleep 5
doneRun this in a separate terminal:
[BASH]
chmod +x monitor-server.sh
./monitor-server.sh
```
### What We Discovered
**During K6 load test with 40 concurrent users:**
```
Time Load CPU% Memory% DB_Conn PHP_Proc
14:30 1.2 28% 45% 45 12
14:31 2.8 35% 48% 89 18
14:32 4.5 42% 52% 127 24
14:33 6.2 38% 55% 150 28 ← Maxed out!
14:34 7.8 35% 58% 150 30
14:35 9.1 32% 61% 150 32 ← Still maxedKey observations:
- CPU stayed under 50% – Not the bottleneck
- Memory stayed under 65% – Not the bottleneck
- Database connections hit 150 – THE BOTTLENECK!
- PHP-FPM processes kept growing – Because requests were queuing
The database connection pool was exhausted.
MySQL was configured with:
[SQL]
SHOW VARIABLES LIKE 'max_connections';
-- Result: 150With 487 queries per page load and 40 concurrent users, we were trying to execute:
487 queries/page × 40 users = 19,480 queries simultaneously
No wonder the database was drowning!
Creating a Performance Report
We compiled all our findings into a diagnostic report:
Critical Issues Found
1. WC Global Product Order Plugin
- Adds 4x postmeta JOIN to every product query
- Single query takes 4.82 seconds
- Impact: 75% of slowness on shop pages
2. Duplicate Queries
- 194 duplicate usermeta queries
- 42 duplicate WPML string translations
- 38 duplicate product meta queries
- Impact: 3.54 seconds wasted per page (43% of DB time)
3. Missing Database Indexes
- No index on
- No index on
- No composite indexes for common query patterns
- Impact: Queries 10-100x slower than they should be
4. WPML Overhead
- Adds 225 queries per page load
- Nested subqueries on every product
- No caching of translation strings
- Impact: 3x increase in total queries
5. Database Connection Pool
- Max connections: 150
- Peak usage: 150 (100% utilized)
- Requests queuing due to connection exhaustion
- Impact: Timeouts under load
Query Analysis Tools Summary
Tools We Used
1. Query Monitor Plugin
- Shows every query and execution time
- Highlights duplicates and slow queries
- Shows caller (which plugin/theme caused it)
- Best for: Initial diagnosis
2. MySQL Slow Query Log
Enable slow query logging:
[SQL]
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 0.5; -- Log queries over 0.5s
SET GLOBAL slow_query_log_file = '/var/log/mysql/slow-query.log';
</sql></pre>Best for: Production monitoring
3. EXPLAIN for Query Analysis
For any slow query, use EXPLAIN to see the execution plan:
[SQL]
EXPLAIN SELECT wp_posts.ID
FROM wp_posts
LEFT JOIN wp_postmeta ON (wp_posts.ID = wp_postmeta.post_id)
WHERE wp_postmeta.meta_key = '_price'
AND wp_posts.post_type = 'product';
Output shows:
- Which indexes are used (or not used)
- How many rows are scanned
- Join type and efficiency
Best for: Understanding specific query problems
The Root Causes Identified
After days of analysis, we had our answers:
Why was the site so slow?
- WC Global Product Order plugin – Single worst offender (75% impact)
- Missing database indexes – Queries 10x slower than necessary
- 236 duplicate queries – Wasting 43% of database time
- WPML overhead – Tripling the query count
- Insufficient connection pool – Only 150 max connections
The good news?
We now knew exactly what to fix and could prioritise by impact.
What’s Next?
With the bottlenecks identified, we could finally start fixing them. The data told us where to focus our optimisation efforts.
In Part 4 of this series, we’ll implement the fixes:
- Removing the WC Global Product Order plugin
- Adding critical database indexes
- Implementing Redis object caching
- Creating a custom plugin to eliminate duplicate queries
- Optimising WPML configuration
- Tuning MySQL connection pool
Each fix will be measured with K6 load testing to prove its impact.
👉 Continue to Part 4: The Optimisation Playbook
About This Series
This is Part 3 of our 5-part case study on WooCommerce performance optimisation:
- Part 1: The Performance Crisis
- Part 2: Load Testing Strategy
- ✅ Part 3: Finding the Bottleneck (You just read this)
- Part 4: The Optimisation Playbook
- Part 5: Results & Lessons Learned









