WooCommerce Sessions Table Bloat — 180,000 Expired Rows Killing Checkout
· 9 min read
A client's WooCommerce store — around 200 orders a day — started throwing intermittent 504 errors on checkout. Not every request, but enough that customers were double-submitting payments and raising disputes. The server had plenty of headroom: 16GB RAM, PHP-FPM workers well within limits, MariaDB barely touching swap. Nothing obvious in the application logs.
The Slow Query Log Told the Story
I enabled the MariaDB slow query log with a 2-second threshold and waited. Within an hour, a pattern emerged:
DELETE FROM wp_woocommerce_sessions WHERE session_expiry < 1747036800;
# Query_time: 47.312 Lock_time: 0.000 Rows_sent: 0 Rows_examined: 183741
Forty-seven seconds for a single DELETE. And during those 47 seconds, every checkout request that needed to read or write a session was queued behind it.
I checked the table size:
SELECT
table_rows,
ROUND(data_length / 1024 / 1024, 2) AS data_mb,
ROUND(index_length / 1024 / 1024, 2) AS index_mb
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name = 'wp_woocommerce_sessions';
+------------+---------+----------+
| table_rows | data_mb | index_mb |
+------------+---------+----------+
| 183741 | 2847.32 | 12.41 |
+------------+---------+----------+
Nearly 3GB in a single table. For context, this store's entire wp_posts table was 640MB.
88% of Sessions Were Already Expired
SELECT
COUNT(*) AS total_sessions,
SUM(CASE WHEN session_expiry < UNIX_TIMESTAMP() THEN 1 ELSE 0 END) AS expired,
SUM(CASE WHEN session_expiry >= UNIX_TIMESTAMP() THEN 1 ELSE 0 END) AS active
FROM wp_woocommerce_sessions;
+----------------+---------+--------+
| total_sessions | expired | active |
+----------------+---------+--------+
| 183741 | 161209 | 22532 |
+----------------+---------+--------+
Over 161,000 expired sessions sitting in the table, never cleaned up. The active session count — 22,532 — was itself suspiciously high for a store with 200 daily orders.
Root Cause 1: The Missing Index
I checked the table's indexes:
SHOW INDEX FROM wp_woocommerce_sessions;
Two indexes: a primary key on session_id and a unique key on session_key. Nothing on session_expiry.
This is the core of the problem. WooCommerce's built-in cleanup runs a DELETE ... WHERE session_expiry < UNIX_TIMESTAMP() query twice daily via wp-cron. Without an index on session_expiry, that query does a full table scan — reading every single row, including the multi-kilobyte serialised cart data in the session_value longtext column, just to find which rows have expired.
On a table with 183,000 rows and nearly 3GB of data, that scan takes long enough to block checkout for real customers. The InnoDB buffer pool fills with session data pages instead of the wp_posts and wp_postmeta pages that actually matter for storefront performance.
This is a known issue. WooCommerce has shipped this table without a session_expiry index since sessions were introduced. As of WooCommerce 9.x, it still isn't there by default.
Root Cause 2: Silent Cleanup Failure
I checked whether the cleanup cron was even running:
wp cron event list --fields=hook,next_run_relative | grep session
Nothing. The woocommerce_cleanup_sessions event was missing entirely. A caching plugin on this site had aggressively pruned wp-cron events it deemed "non-essential" — including WooCommerce's session garbage collection. The sessions had been accumulating for months without any cleanup running at all.
Root Cause 3: Bots Inflating Session Count
The 22,532 active sessions for a 200-order-per-day store was too high. I checked the average session data size to understand what was creating all these sessions:
SELECT
ROUND(AVG(LENGTH(session_value)) / 1024, 2) AS avg_kb,
ROUND(MAX(LENGTH(session_value)) / 1024, 2) AS max_kb
FROM wp_woocommerce_sessions
WHERE session_expiry >= UNIX_TIMESTAMP();
Average session size was 1.8KB, but thousands of sessions contained only empty cart data — a signature of bots and crawlers hitting WooCommerce AJAX endpoints. Every bot that triggered a /?wc-ajax=get_refreshed_fragments request created a new session, because bots don't persist cookies between requests.
The Fix
Step 1: Add the Missing Index
Before touching any data, I added the index that should have been there from the start:
ALTER TABLE wp_woocommerce_sessions ADD INDEX idx_session_expiry (session_expiry);
This took about 90 seconds on a 3GB table. I ran it during a low-traffic window to avoid locking issues.
Step 2: Batch-Delete Expired Sessions
A single DELETE of 161,000 rows would lock the table for minutes. Instead, I ran it in batches:
#!/bin/bash
DELETED=1
while [ "$DELETED" -gt 0 ]; do
DELETED=$(wp db query \
"DELETE FROM wp_woocommerce_sessions WHERE session_expiry < UNIX_TIMESTAMP() LIMIT 1000; SELECT ROW_COUNT();" \
--skip-column-names 2>/dev/null | tail -1)
echo "Deleted: $DELETED"
sleep 0.5
done
The half-second sleep between batches let checkout requests through without queuing. The entire cleanup finished in about 12 minutes.
Step 3: Reclaim Disk Space
Deleting rows from an InnoDB table doesn't return disk space to the OS. The tablespace file stays the same size until you explicitly reclaim it:
OPTIMIZE TABLE wp_woocommerce_sessions;
Table size dropped from 2,847MB to 186MB.
Step 4: Re-register the Cleanup Cron
The missing cron event needed to be restored. I verified WooCommerce's session handler would re-register it on the next page load by checking that no plugin was actively preventing it, then triggered a re-registration:
wp eval 'WC()->session->init_session_cookie();'
wp cron event list --fields=hook,next_run_relative | grep session
woocommerce_cleanup_sessions 11 hours 42 minutes
Back in place.
Step 5: Block Bots from Creating Sessions
I added an nginx rule to block known bots from WooCommerce AJAX endpoints that trigger session creation:
map $http_user_agent $wc_bot_block {
default 0;
~*(bot|crawler|spider|scraper|semrush|ahrefs|mj12bot|dotbot|bytespider|gptbot) 1;
}
server {
# Block bots from WooCommerce AJAX that creates sessions
location ~ ^/\?wc-ajax= {
if ($wc_bot_block) {
return 403;
}
try_files $uri $uri/ /index.php$is_args$args;
}
}
Prevention
After this cleanup, I added a monitoring check to catch session bloat before it becomes a problem again. This runs daily via system cron across all managed WooCommerce sites:
wp db query "SELECT COUNT(*) FROM wp_woocommerce_sessions WHERE session_expiry < UNIX_TIMESTAMP();" --skip-column-names
If the expired count exceeds 5,000, it fires an alert. On a healthy store with working cleanup, expired sessions should never accumulate beyond a few hundred between cleanup runs.
I also shortened the session expiry for guest users from the default 48 hours to 24 hours. Most abandoned carts are abandoned within the first hour — holding sessions for two full days is rarely necessary:
add_filter( 'wc_session_expiration', function () {
return 24 * HOUR_IN_SECONDS;
});
The Result
Checkout response times dropped from 8-12 seconds (with spikes to 504) back to a consistent 1.2 seconds. The sessions table now hovers around 8,000-12,000 rows instead of 183,000. The MariaDB slow query log is clean.
This is the fourth database bloat vector I've written about on this blog, after wp_options autoload bloat, postmeta orphan cleanup, and Action Scheduler table growth. If you're running WooCommerce at any real scale, all four will catch up with you eventually. The difference is whether you find them proactively or your customers find them at checkout.
Stop Firefighting. Start Maintaining.
I manage 70+ WordPress sites for agencies and businesses. Whether you need ongoing maintenance, emergency support, or a one-off performance fix — I can help.
