WooCommerce Performance
Overview
WooCommerce performance problems typically stem from five sources: expensive product queries with un-indexed meta tables, unbounded AJAX cart/checkout calls, missing persistent object cache, bloated wp_options autoload data, and a growing wp_woocommerce_sessions and order table without cleanup. This skill covers profiling, query optimization, Redis object caching, and scheduled maintenance routines.
When to Use This Skill
- When store pages are slow to load under moderate traffic (hundreds of concurrent users)
- When server CPU spikes during WooCommerce AJAX calls (add-to-cart, shipping calculation)
- When MySQL query time shows in New Relic or Query Monitor as the primary bottleneck
- When the
wp_options table autoload size exceeds 1–2 MB
- When
wp_woocommerce_sessions has millions of rows slowing down session lookups
- When implementing Redis or Memcached to reduce MySQL load on a high-traffic store
Core Instructions
Profile first with Query Monitor
Install Query Monitor plugin to identify slow queries in the admin and frontend:
# Via WP-CLI
wp plugin install query-monitor --activate
Focus on:
- Queries per page load (> 50 is a concern)
- Duplicate queries (same query run multiple times)
- Slow queries (> 100ms individual queries)
- Large result sets (queries returning thousands of rows)
For production profiling, enable MySQL slow query log:
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 0.5;
SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log';
Enable Redis persistent object cache
The single highest-impact optimization for WooCommerce. Without it, every page load re-fetches the same product data from MySQL:
# Install Redis server
sudo apt install redis-server
# Install the Redis Object Cache plugin
wp plugin install redis-cache --activate
wp redis enable
Configure in wp-config.php:
define('WP_REDIS_HOST', '127.0.0.1');
define('WP_REDIS_PORT', 6379);
define('WP_REDIS_DATABASE', 0);
define('WP_REDIS_TIMEOUT', 1);
define('WP_REDIS_READ_TIMEOUT', 1);
define('WP_REDIS_MAXTTL', 86400); // 24 hours max TTL
// Selective cache groups — exclude user sessions from Redis (they change constantly)
define('WP_REDIS_IGNORED_GROUPS', ['wc_session_id', 'counts', 'plugins']);
Optimize WooCommerce-specific database queries
<?php
// Add missing indexes for common WooCommerce product queries
// Run once via WP-CLI: wp eval-file add-indexes.php
global $wpdb;
// Index for product meta queries (stock status, visibility, price)
$wpdb->query("
ALTER TABLE {$wpdb->postmeta}
ADD INDEX wc_product_meta_lookup (meta_key(32), meta_value(64))
");
// Index for order meta queries
$wpdb->query("
ALTER TABLE {$wpdb->postmeta}
ADD INDEX wc_order_customer_lookup (meta_key(32), meta_value(100))
");
Use WooCommerce's HPOS (High-Performance Order Storage) to move orders out of post meta:
// wp-config.php — enable HPOS (WooCommerce 7.1+)
// This is configured via WooCommerce Settings → Advanced → Features
// Enables dedicated order tables: wp_wc_orders, wp_wc_order_items, etc.
# Check HPOS status
wp wc hpos status
# Migrate orders to HPOS
wp wc hpos migrate --batch-size=500
Clean up transients, sessions, and log tables
<?php
// Scheduled cleanup — run daily via Action Scheduler
add_action('my_plugin_daily_cleanup', function () {
global $wpdb;
// Delete expired WooCommerce sessions (older than 48 hours)
$expiry_threshold = time() - (48 * HOUR_IN_SECONDS);
$wpdb->query($wpdb->prepare("
DELETE FROM {$wpdb->prefix}woocommerce_sessions
WHERE session_expiry < %d
LIMIT 5000
", $expiry_threshold));
// Delete expired transients
$wpdb->query("
DELETE FROM {$wpdb->options}
WHERE option_name LIKE '_transient_timeout_%'
AND option_value < UNIX_TIMESTAMP()
LIMIT 5000
");
$wpdb->query("
DELETE t FROM {$wpdb->options} t
LEFT JOIN {$wpdb->options} e
ON e.option_name = REPLACE(t.option_name, '_transient_', '_transient_timeout_')
WHERE t.option_name LIKE '_transient_%'
AND e.option_id IS NULL
LIMIT 5000
");
// Rotate WooCommerce logs older than 30 days
$wpdb->query("
DELETE FROM {$wpdb->prefix}woocommerce_log
WHERE timestamp < DATE_SUB(NOW(), INTERVAL 30 DAY)
LIMIT 10000
");
});
// Register the scheduled event
add_action('init', function () {
if (!as_next_scheduled_action('my_plugin_daily_cleanup')) {
as_schedule_recurring_action(strtotime('tomorrow midnight'), DAY_IN_SECONDS, 'my_plugin_daily_cleanup');
}
});
Optimize the wp_options autoload table
Autoloaded options are loaded on every page request. Bloated autoload causes significant overhead:
-- Find large autoloaded options
SELECT option_name, LENGTH(option_value) as size_bytes
FROM wp_options
WHERE autoload = 'yes'
ORDER BY LENGTH(option_value) DESC
LIMIT 30;
<?php
// Fix: Store plugin data as transients or post meta instead of autoloaded options
// Bad — autoloaded, loaded on every request
update_option('my_plugin_product_cache', $large_array, true);
// Good — not autoloaded
update_option('my_plugin_product_cache', $large_array, false);
// Better — use transient with expiry
set_transient('my_plugin_product_cache', $large_array, HOUR_IN_SECONDS);
// Disable autoload for existing options
global $wpdb;
$wpdb->update(
$wpdb->options,
['autoload' => 'no'],
['option_name' => 'woocommerce_attribute_taxonomies']
);
Examples
Fragment caching for product loops
<?php
// Cache rendered product card HTML in Redis to avoid re-rendering
function render_product_card_cached(int $product_id): string {
$cache_key = "product_card_{$product_id}_" . get_locale();
$cached = wp_cache_get($cache_key, 'product_cards');
if ($cached !== false) {
return $cached;
}
$product = wc_get_product($product_id);
if (!$product) return '';
ob_start();
wc_get_template('content-product.php', ['product' => $product]);
$html = ob_get_clean();
// Cache for 30 minutes; invalidate on product update
wp_cache_set($cache_key, $html, 'product_cards', 30 * MINUTE_IN_SECONDS);
return $html;
}
// Purge cache when product is updated
add_action('woocommerce_update_product', function (int $product_id) {
wp_cache_delete("product_card_{$product_id}_" . get_locale(), 'product_cards');
// Also delete all locale variants
wp_cache_flush_group('product_cards');
});
Detect and fix N+1 product queries
<?php
// Bad — triggers N+1 queries (one per product in loop)
$product_ids = wc_get_featured_product_ids();
foreach ($product_ids as $id) {
$product = wc_get_product($id); // <-- separate query for each product
echo $product->get_name();
}
// Good — prime the cache before the loop
$product_ids = wc_get_featured_product_ids();
// Pre-warm the object cache with all products in a single query
$products = array_map('wc_get_product', $product_ids); // Still N queries without this:
// Better — use WC_Product_Query with ID filter (single query)
$products = wc_get_products([
'include' => $product_ids,
'limit' => count($product_ids),
'return' => 'objects',
]);
foreach ($products as $product) {
echo $product->get_name(); // data already loaded
}
Best Practices
- Enable HPOS (High-Performance Order Storage) on WooCommerce 7.1+ stores — it moves order data into dedicated tables with proper indexes, eliminating the
wp_postmeta bottleneck for orders
- Add Redis/Memcached object cache before anything else — it eliminates redundant MySQL queries that WordPress and WooCommerce make on every request and often cuts DB load by 60–80%
- Run cleanup on off-peak hours via Action Scheduler — batch DELETE operations (limit 5000 rows per run) to avoid long table locks during cleanup
- Use
WP_DEBUG_LOG with SAVEQUERIES only on dev — enabling these on production kills performance; use APM tools (New Relic, Datadog) for production profiling
- Paginate admin order queries — loading all orders in one go (
posts_per_page: -1) locks MySQL and causes timeouts; always use paged with posts_per_page ≤ 100
- Set
WP_REDIS_IGNORED_GROUPS to exclude volatile data (cart, session counters) from Redis to prevent cache stampedes on high-traffic checkout pages
- Monitor
wp_options table size weekly — set up an alert if autoloaded data exceeds 800KB; common culprits are plugins that store large arrays as autoloaded options
Common Pitfalls
| Problem |
Solution |
| Redis cache not reducing MySQL queries |
Check wp_cache_get hit rate in Query Monitor — a low hit rate means cache keys are being invalidated too aggressively or TTLs are too short |
| Cleanup queries cause table-level locks |
Use row-level LIMIT clauses in DELETE queries (max 5000 rows per run) and schedule them during low-traffic windows |
| HPOS migration breaks third-party plugins |
Audit all plugins for get_post_meta(order_id, ...) usage before enabling HPOS — these must be updated to use $order->get_meta() |
| Slow product listing despite indexes |
Check whether wc_lookup_table_enabled is active; WooCommerce 3.7+ introduced wp_wc_product_meta_lookup table that dramatically speeds up product queries |
| Object cache stale after product import |
Call wp_cache_flush() or wp_cache_flush_group('products') at the end of bulk import scripts to invalidate stale product caches |
| Session table grows despite cleanup |
Ensure WC_Session_Handler is using the database backend (not PHP sessions); default cookie-based sessions don't create DB rows, but custom plugins may force DB sessions |
Related Skills
- @woocommerce-plugin-development
- @woocommerce-rest-api
- @database-query-optimization
- @caching-strategies
- @infrastructure-performance
1---2name: woocommerce-performance3description: Fix slow WooCommerce stores by optimizing database queries, clearing transients, enabling Redis object caching, and configuring page caching4---56# WooCommerce Performance78## Overview910WooCommerce performance problems typically stem from five sources: expensive product queries with un-indexed meta tables, unbounded AJAX cart/checkout calls, missing persistent object cache, bloated `wp_options` autoload data, and a growing `wp_woocommerce_sessions` and order table without cleanup. This skill covers profiling, query optimization, Redis object caching, and scheduled maintenance routines.1112## When to Use This Skill1314- When store pages are slow to load under moderate traffic (hundreds of concurrent users)15- When server CPU spikes during WooCommerce AJAX calls (add-to-cart, shipping calculation)16- When MySQL query time shows in New Relic or Query Monitor as the primary bottleneck17- When the `wp_options` table autoload size exceeds 1–2 MB18- When `wp_woocommerce_sessions` has millions of rows slowing down session lookups19- When implementing Redis or Memcached to reduce MySQL load on a high-traffic store2021## Core Instructions22231. **Profile first with Query Monitor**2425 Install Query Monitor plugin to identify slow queries in the admin and frontend:2627 ```bash28 # Via WP-CLI29 wp plugin install query-monitor --activate30 ```3132 Focus on:33 - Queries per page load (> 50 is a concern)34 - Duplicate queries (same query run multiple times)35 - Slow queries (> 100ms individual queries)36 - Large result sets (queries returning thousands of rows)3738 For production profiling, enable MySQL slow query log:3940 ```sql41 SET GLOBAL slow_query_log = 'ON';42 SET GLOBAL long_query_time = 0.5;43 SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log';44 ```45462. **Enable Redis persistent object cache**4748 The single highest-impact optimization for WooCommerce. Without it, every page load re-fetches the same product data from MySQL:4950 ```bash51 # Install Redis server52 sudo apt install redis-server5354 # Install the Redis Object Cache plugin55 wp plugin install redis-cache --activate56 wp redis enable57 ```5859 Configure in `wp-config.php`:6061 ```php62 define('WP_REDIS_HOST', '127.0.0.1');63 define('WP_REDIS_PORT', 6379);64 define('WP_REDIS_DATABASE', 0);65 define('WP_REDIS_TIMEOUT', 1);66 define('WP_REDIS_READ_TIMEOUT', 1);67 define('WP_REDIS_MAXTTL', 86400); // 24 hours max TTL6869 // Selective cache groups — exclude user sessions from Redis (they change constantly)70 define('WP_REDIS_IGNORED_GROUPS', ['wc_session_id', 'counts', 'plugins']);71 ```72733. **Optimize WooCommerce-specific database queries**7475 ```php76 <?php7778 // Add missing indexes for common WooCommerce product queries79 // Run once via WP-CLI: wp eval-file add-indexes.php8081 global $wpdb;8283 // Index for product meta queries (stock status, visibility, price)84 $wpdb->query("85 ALTER TABLE {$wpdb->postmeta}86 ADD INDEX wc_product_meta_lookup (meta_key(32), meta_value(64))87 ");8889 // Index for order meta queries90 $wpdb->query("91 ALTER TABLE {$wpdb->postmeta}92 ADD INDEX wc_order_customer_lookup (meta_key(32), meta_value(100))93 ");94 ```9596 Use WooCommerce's HPOS (High-Performance Order Storage) to move orders out of post meta:9798 ```php99 // wp-config.php — enable HPOS (WooCommerce 7.1+)100 // This is configured via WooCommerce Settings → Advanced → Features101 // Enables dedicated order tables: wp_wc_orders, wp_wc_order_items, etc.102 ```103104 ```bash105 # Check HPOS status106 wp wc hpos status107 # Migrate orders to HPOS108 wp wc hpos migrate --batch-size=500109 ```1101114. **Clean up transients, sessions, and log tables**112113 ```php114 <?php115 // Scheduled cleanup — run daily via Action Scheduler116117 add_action('my_plugin_daily_cleanup', function () {118 global $wpdb;119120 // Delete expired WooCommerce sessions (older than 48 hours)121 $expiry_threshold = time() - (48 * HOUR_IN_SECONDS);122 $wpdb->query($wpdb->prepare("123 DELETE FROM {$wpdb->prefix}woocommerce_sessions124 WHERE session_expiry < %d125 LIMIT 5000126 ", $expiry_threshold));127128 // Delete expired transients129 $wpdb->query("130 DELETE FROM {$wpdb->options}131 WHERE option_name LIKE '_transient_timeout_%'132 AND option_value < UNIX_TIMESTAMP()133 LIMIT 5000134 ");135 $wpdb->query("136 DELETE t FROM {$wpdb->options} t137 LEFT JOIN {$wpdb->options} e138 ON e.option_name = REPLACE(t.option_name, '_transient_', '_transient_timeout_')139 WHERE t.option_name LIKE '_transient_%'140 AND e.option_id IS NULL141 LIMIT 5000142 ");143144 // Rotate WooCommerce logs older than 30 days145 $wpdb->query("146 DELETE FROM {$wpdb->prefix}woocommerce_log147 WHERE timestamp < DATE_SUB(NOW(), INTERVAL 30 DAY)148 LIMIT 10000149 ");150 });151152 // Register the scheduled event153 add_action('init', function () {154 if (!as_next_scheduled_action('my_plugin_daily_cleanup')) {155 as_schedule_recurring_action(strtotime('tomorrow midnight'), DAY_IN_SECONDS, 'my_plugin_daily_cleanup');156 }157 });158 ```1591605. **Optimize the wp_options autoload table**161162 Autoloaded options are loaded on every page request. Bloated autoload causes significant overhead:163164 ```sql165 -- Find large autoloaded options166 SELECT option_name, LENGTH(option_value) as size_bytes167 FROM wp_options168 WHERE autoload = 'yes'169 ORDER BY LENGTH(option_value) DESC170 LIMIT 30;171 ```172173 ```php174 <?php175 // Fix: Store plugin data as transients or post meta instead of autoloaded options176177 // Bad — autoloaded, loaded on every request178 update_option('my_plugin_product_cache', $large_array, true);179180 // Good — not autoloaded181 update_option('my_plugin_product_cache', $large_array, false);182183 // Better — use transient with expiry184 set_transient('my_plugin_product_cache', $large_array, HOUR_IN_SECONDS);185186 // Disable autoload for existing options187 global $wpdb;188 $wpdb->update(189 $wpdb->options,190 ['autoload' => 'no'],191 ['option_name' => 'woocommerce_attribute_taxonomies']192 );193 ```194195## Examples196197### Fragment caching for product loops198199```php200<?php201202// Cache rendered product card HTML in Redis to avoid re-rendering203function render_product_card_cached(int $product_id): string {204 $cache_key = "product_card_{$product_id}_" . get_locale();205 $cached = wp_cache_get($cache_key, 'product_cards');206207 if ($cached !== false) {208 return $cached;209 }210211 $product = wc_get_product($product_id);212 if (!$product) return '';213214 ob_start();215 wc_get_template('content-product.php', ['product' => $product]);216 $html = ob_get_clean();217218 // Cache for 30 minutes; invalidate on product update219 wp_cache_set($cache_key, $html, 'product_cards', 30 * MINUTE_IN_SECONDS);220221 return $html;222}223224// Purge cache when product is updated225add_action('woocommerce_update_product', function (int $product_id) {226 wp_cache_delete("product_card_{$product_id}_" . get_locale(), 'product_cards');227 // Also delete all locale variants228 wp_cache_flush_group('product_cards');229});230```231232### Detect and fix N+1 product queries233234```php235<?php236237// Bad — triggers N+1 queries (one per product in loop)238$product_ids = wc_get_featured_product_ids();239foreach ($product_ids as $id) {240 $product = wc_get_product($id); // <-- separate query for each product241 echo $product->get_name();242}243244// Good — prime the cache before the loop245$product_ids = wc_get_featured_product_ids();246// Pre-warm the object cache with all products in a single query247$products = array_map('wc_get_product', $product_ids); // Still N queries without this:248249// Better — use WC_Product_Query with ID filter (single query)250$products = wc_get_products([251 'include' => $product_ids,252 'limit' => count($product_ids),253 'return' => 'objects',254]);255256foreach ($products as $product) {257 echo $product->get_name(); // data already loaded258}259```260261## Best Practices262263- **Enable HPOS** (High-Performance Order Storage) on WooCommerce 7.1+ stores — it moves order data into dedicated tables with proper indexes, eliminating the `wp_postmeta` bottleneck for orders264- **Add Redis/Memcached object cache before anything else** — it eliminates redundant MySQL queries that WordPress and WooCommerce make on every request and often cuts DB load by 60–80%265- **Run cleanup on off-peak hours via Action Scheduler** — batch DELETE operations (limit 5000 rows per run) to avoid long table locks during cleanup266- **Use `WP_DEBUG_LOG` with `SAVEQUERIES` only on dev** — enabling these on production kills performance; use APM tools (New Relic, Datadog) for production profiling267- **Paginate admin order queries** — loading all orders in one go (`posts_per_page: -1`) locks MySQL and causes timeouts; always use `paged` with `posts_per_page` ≤ 100268- **Set `WP_REDIS_IGNORED_GROUPS`** to exclude volatile data (cart, session counters) from Redis to prevent cache stampedes on high-traffic checkout pages269- **Monitor `wp_options` table size weekly** — set up an alert if autoloaded data exceeds 800KB; common culprits are plugins that store large arrays as autoloaded options270271## Common Pitfalls272273| Problem | Solution |274|---------|----------|275| Redis cache not reducing MySQL queries | Check `wp_cache_get` hit rate in Query Monitor — a low hit rate means cache keys are being invalidated too aggressively or TTLs are too short |276| Cleanup queries cause table-level locks | Use row-level `LIMIT` clauses in DELETE queries (max 5000 rows per run) and schedule them during low-traffic windows |277| HPOS migration breaks third-party plugins | Audit all plugins for `get_post_meta(order_id, ...)` usage before enabling HPOS — these must be updated to use `$order->get_meta()` |278| Slow product listing despite indexes | Check whether `wc_lookup_table_enabled` is active; WooCommerce 3.7+ introduced `wp_wc_product_meta_lookup` table that dramatically speeds up product queries |279| Object cache stale after product import | Call `wp_cache_flush()` or `wp_cache_flush_group('products')` at the end of bulk import scripts to invalidate stale product caches |280| Session table grows despite cleanup | Ensure `WC_Session_Handler` is using the database backend (not PHP sessions); default cookie-based sessions don't create DB rows, but custom plugins may force DB sessions |281282## Related Skills283284- @woocommerce-plugin-development285- @woocommerce-rest-api286- @database-query-optimization287- @caching-strategies288- @infrastructure-performance