How 47,000 Expired Transients in wp_options Brought a WooCommerce Store to Its Knees
· 12 min read
A client's WooCommerce store had been getting progressively slower over about six months. Not dramatically — the kind of slow that creeps up gradually enough that nobody sounds the alarm. Checkout had drifted from 1.8 seconds to 4.5 seconds. The admin dashboard took three seconds to render. Every AJAX request was sluggish.
The server was fine. PHP-FPM workers had headroom, MariaDB connections were nowhere near the limit, and there was no persistent object cache in place — just the default WordPress database-backed transient storage. I'd already checked and tuned OPcache on this box a few months earlier. Nothing obvious was wrong.
Then I looked at the wp_options table.
The wp_options Table Was 380MB
That number stopped me. A healthy wp_options table on a WooCommerce store with 30 plugins should be 5-15MB. This one was 380MB and contained over 94,000 rows.
SELECT COUNT(*) AS total_rows,
ROUND(SUM(LENGTH(option_value)) / 1024 / 1024, 1) AS total_mb
FROM wp_options;
+------------+----------+
| total_rows | total_mb |
+------------+----------+
| 94217 | 378.4 |
+------------+----------+
The next question was obvious: what's in there?
SELECT
CASE
WHEN option_name LIKE '_transient_timeout_%' THEN 'transient_timeout'
WHEN option_name LIKE '_transient_%' THEN 'transient_data'
WHEN option_name LIKE '_site_transient_timeout_%' THEN 'site_transient_timeout'
WHEN option_name LIKE '_site_transient_%' THEN 'site_transient_data'
ELSE 'regular_option'
END AS row_type,
COUNT(*) AS row_count,
ROUND(SUM(LENGTH(option_value)) / 1024 / 1024, 1) AS size_mb
FROM wp_options
GROUP BY row_type
ORDER BY row_count DESC;
+------------------------+-----------+---------+
| row_type | row_count | size_mb |
+------------------------+-----------+---------+
| transient_data | 47104 | 341.2 |
| transient_timeout | 46891 | 0.4 |
| regular_option | 198 | 1.8 |
| site_transient_data | 18 | 0.3 |
| site_transient_timeout | 6 | 0.0 |
+------------------------+-----------+---------+
47,104 transient data rows and 46,891 timeout rows. Transients accounted for 99.8% of the table. The 198 actual options — the ones WordPress and the plugins genuinely need — were buried under nearly 94,000 rows of cached data that should have been cleaned up months ago.
Why Transients Pile Up
WordPress transients are a caching mechanism. A plugin calls set_transient('my_data', $value, 3600) to store something for an hour. When the hour passes, that data should disappear. But it doesn't — not automatically, not reliably.
WordPress uses lazy deletion. When get_transient('my_data') is called and the transient has expired, WordPress deletes the two rows (the data row and the timeout row) at that point and returns false. If nobody ever calls get_transient() for that key again, the expired rows sit in wp_options forever.
There is a daily cleanup cron task — delete_expired_transients() hooked to wp_scheduled_delete. But this only fires if WP-Cron is running reliably. This client had DISABLE_WP_CRON set to true in wp-config.php (which I'd recommended — WP-Cron triggered by page visits is unreliable on production stores). They had a system cron running wp cron event run --due-now every five minutes, which should have caught the daily cleanup event. But at some point the cron job had stopped working — the server had been migrated to a new user account six months earlier, and the cron entry was still running as the old user, silently failing with permission errors.
Six months of expired transients, never cleaned up.
Finding the Worst Offenders
I needed to know which plugins had created this mess. Grouping transients by their key prefix identified the culprits:
SELECT
SUBSTRING_INDEX(REPLACE(option_name, '_transient_', ''), '_', 2) AS prefix,
COUNT(*) AS row_count,
ROUND(SUM(LENGTH(option_value)) / 1024 / 1024, 1) AS size_mb
FROM wp_options
WHERE option_name LIKE '_transient_%'
AND option_name NOT LIKE '_transient_timeout_%'
GROUP BY prefix
ORDER BY size_mb DESC
LIMIT 15;
+--------------------+-----------+---------+
| prefix | row_count | size_mb |
+--------------------+-----------+---------+
| wc_var | 18420 | 224.6 |
| wc_product | 9840 | 52.3 |
| wc_related | 8200 | 38.1 |
| wc_ship | 4120 | 14.8 |
| feed_ | 3240 | 6.2 |
| wc_products | 890 | 3.1 |
| dash_v2 | 720 | 0.8 |
| wpforms_t15s | 410 | 0.6 |
| external_ip | 264 | 0.1 |
+--------------------+-----------+---------+
WooCommerce was responsible for over 95% of the bloat. The wc_var_prices_{product_id} transients alone — cached price ranges for variable products — accounted for 224MB across 18,420 rows. The store had around 2,400 products, many with multiple variations. Each product generated multiple transients keyed by a hash of tax settings, and when those transients expired, new ones were created with different hashes. The old ones were never cleaned up.
The feed_ transients were from the WordPress dashboard RSS feed widget. Every time an admin logged in, WordPress fetched the news feed and cached it as a transient. Over six months, hundreds of expired feed caches had accumulated.
How Many Were Actually Expired?
SELECT COUNT(*) AS expired_count
FROM wp_options
WHERE option_name LIKE '_transient_timeout_%'
AND option_value < UNIX_TIMESTAMP();
+---------------+
| expired_count |
+---------------+
| 44,312 |
+---------------+
44,312 out of 46,891 timeout entries were expired. 94% of all transients in the database were dead weight.
The Cleanup
WP-CLI made the initial cleanup straightforward:
wp transient delete --expired --path=/var/www/store
Success: 44312 expired transients deleted from the database.
One command, 44,312 expired transient pairs removed. But I also needed to check for orphaned rows — timeout entries without matching data rows, and data rows whose timeout entry was already gone.
Orphaned timeouts (timeout row exists, but the data row is missing):
SELECT COUNT(*) AS orphaned_timeouts
FROM wp_options t
WHERE t.option_name LIKE '_transient_timeout_%'
AND NOT EXISTS (
SELECT 1 FROM wp_options d
WHERE d.option_name = CONCAT('_transient_', SUBSTRING(t.option_name, 20))
);
Orphaned data rows (data row exists, but the timeout row is missing — these contain the actual cached payloads and are usually the bigger source of bloat):
SELECT COUNT(*) AS orphaned_data_rows
FROM wp_options d
WHERE d.option_name LIKE '_transient_%'
AND d.option_name NOT LIKE '_transient_timeout_%'
AND NOT EXISTS (
SELECT 1 FROM wp_options t
WHERE t.option_name = CONCAT('_transient_timeout_', SUBSTRING(d.option_name, 12))
);
There were 213 orphaned timeout rows and 87 orphaned data rows. These don't cause functional problems, but the orphaned data rows in particular add table bloat since they hold the cached payloads. I cleaned both sets with targeted DELETE queries using the same WHERE clauses.
After the cleanup, I reclaimed the disk space:
wp db query "OPTIMIZE TABLE wp_options;" --path=/var/www/store
The table dropped from 380MB to 4.2MB. The COUNT(*) went from 94,217 to 2,614 rows.
The Performance Difference
Before cleanup, I'd captured the MariaDB slow query log entry for the alloptions query:
# Query_time: 0.847
SELECT option_name, option_value FROM wp_options WHERE autoload IN ('yes','on','auto-on','auto');
After cleanup:
# Query_time: 0.003
From 847ms to 3ms. That single query runs on every WordPress request — every page load, every AJAX call, every REST API hit, every cron tick. The 844ms improvement cascaded across every interaction with the site.
Checkout dropped from 4.5 seconds to 1.6 seconds. The admin dashboard went from three seconds to under a second.
Preventing It From Happening Again
The cleanup was the easy part. Preventing recurrence required three changes.
Fix the system cron. The broken cron job was the root cause. I updated it to run as the correct user and verified it was firing:
# /etc/cron.d/wordpress-store
*/5 * * * * www-data /usr/local/bin/wp cron event run --due-now --path=/var/www/store 2>&1 | logger -t wp-cron-store
Piping to logger means failures show up in syslog instead of vanishing silently.
Add a dedicated transient cleanup cron. WordPress's daily cleanup is a single cron event that can be missed. I added a separate weekly cleanup as a belt-and-braces measure:
# /etc/cron.d/wordpress-transient-cleanup
0 3 * * 0 www-data /usr/local/bin/wp transient delete --expired --path=/var/www/store 2>&1 | logger -t wp-transients
Install Redis. This is the permanent fix. When a persistent object cache is active, WordPress routes all transient operations through the cache backend instead of wp_options. Redis handles TTL expiration natively — expired keys are evicted automatically. No rows accumulate in the database because transients never touch the database.
After enabling Redis Object Cache on this site, I ran wp transient type to confirm:
wp transient type --path=/var/www/store
Transients are saved to the object cache.
One caveat: enabling Redis doesn't clean up legacy transients that were already in wp_options from before the cache was active. The delete_expired_transients() function skips database cleanup when an external object cache is detected. Run wp transient delete --expired once after enabling Redis — the --expired flag forces a database cleanup regardless of the cache backend.
The Monitoring Script
I now run this check across all sites I manage as part of my weekly maintenance sweep:
#!/bin/bash
TRANSIENT_COUNT=$(wp db query "
SELECT COUNT(*)
FROM wp_options
WHERE option_name LIKE '_transient_timeout_%'
AND option_value < UNIX_TIMESTAMP();
" --skip-column-names --path="$1" 2>/dev/null)
if ! [[ "$TRANSIENT_COUNT" =~ ^[0-9]+$ ]]; then
echo "CRITICAL: wp-cli failed or returned non-numeric output for $1"
exit 2
fi
if [ "$TRANSIENT_COUNT" -gt 500 ]; then
echo "WARNING: ${TRANSIENT_COUNT} expired transients in wp_options"
exit 1
fi
echo "OK: ${TRANSIENT_COUNT} expired transients"
Anything over 500 expired transients triggers an alert. On a site with Redis, this number should always be zero. On sites without Redis, it should stay low as long as WP-Cron is firing reliably.
The Bigger Picture
Transient bloat is a silent problem. The site still works, orders still process, no errors appear in the logs. It just gets a fraction of a second slower each week as expired rows accumulate. Over six months, that fraction adds up to seconds on every page load — and nobody notices until a customer complains about checkout being slow.
If you're managing WooCommerce stores without Redis, check your wp_options table. Count the transients. You might be surprised.
Stop Firefighting. Start Maintaining.
I manage 70+ WordPress and WooCommerce sites. Transient bloat is one of the silent performance killers that routine maintenance catches before your visitors notice. Whether you need ongoing database optimisation or a full maintenance plan — I can help.
