WooCommerce Action Scheduler bloat: how I reclaimed 14GB and fixed 30-second page loads
· 13 min read
A client messaged me on a Friday afternoon — their WooCommerce store's nightly backup had failed. The hosting provider's automated backup system had timed out because the database had grown to 18.4GB. This was a mid-sized store doing around 150 orders a day. There was no reason for a database that large.
A day earlier, I'd finished dealing with the same underlying problem on a different site — a store doing around 200 orders a day across two storefronts, where the admin dashboard took 30 seconds to load and customers were abandoning checkout because the payment confirmation page timed out. Two different sites, two different symptoms, one cause: Action Scheduler table bloat. This post covers both, because between them they show the full range of how this problem presents.
Finding the Culprit
On the first site, I SSH'd into the server and checked which tables were eating all the space:
SELECT table_name,
ROUND(data_length / 1024 / 1024, 2) AS data_mb,
ROUND(index_length / 1024 / 1024, 2) AS index_mb,
table_rows
FROM information_schema.tables
WHERE table_schema = 'wp_production'
ORDER BY data_length DESC
LIMIT 10;
The results told the whole story:
| Table | Data (MB) | Rows |
|---|---|---|
wp_actionscheduler_actions |
8,412 | 26,700,000 |
wp_actionscheduler_logs |
5,891 | 41,200,000 |
wp_posts |
1,240 | 385,000 |
wp_postmeta |
980 | 2,100,000 |
Two tables — wp_actionscheduler_actions and wp_actionscheduler_logs — accounted for 14.3GB of the 18.4GB database. The wp_posts table, which held the actual content, orders, and products, was barely over a gigabyte.
The second site's numbers were smaller but proportionally just as bad: 12.8 million rows in wp_actionscheduler_actions and 38.5 million in the logs table, together making up 94% of the entire database.
Understanding the Problem
Action Scheduler is the background job system built into WooCommerce. Every order confirmation email, every subscription renewal check, every webhook delivery, every inventory sync, every analytics update — they all run through Action Scheduler. On a busy store that's easily 8-12 actions per order. Each execution creates a row in wp_actionscheduler_actions and one or more rows in wp_actionscheduler_logs.
By default, Action Scheduler cleans up completed and cancelled actions older than 31 days. It runs this cleanup every minute, deleting 20 rows per batch. The maths doesn't work on a busy store. If a site generates 5,000 actions per day (not unusual for a WooCommerce store with subscriptions, emails, and analytics plugins), that's 155,000 actions per month. At 20 deletions per cleanup cycle, it would take the built-in cleanup over 5 days of continuous work just to clear one month's worth — and new actions are being created the entire time. Even at a more modest 2,000 new rows a day, the cleanup falls behind and never catches up. Worse, once the table is huge, the cleanup process itself starts timing out — stuck behind the very bloat it's trying to clear.
There's a second problem: failed actions are never cleaned up. The default cleanup only targets complete and canceled statuses. Failed actions sit in the table forever.
Why It Kills Performance, Not Just Backups
On the first site the symptom was a failed backup. On the second it was raw performance — and the mechanism is worth understanding.
Every time WordPress loads the WooCommerce admin, the Scheduled Actions page, or processes an order, it queries wp_actionscheduler_actions. With 12 million rows, even indexed queries were slow because the InnoDB buffer pool couldn't hold the indexes in memory. That server had 8GB of RAM with innodb_buffer_pool_size set to 2GB — but the Action Scheduler indexes alone needed over 3GB.
I confirmed it by checking the buffer pool hit ratio:
SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_read%';
The hit ratio was 74% — one in four index lookups was hitting disk. For a WordPress database you want this above 99%. That's what a 30-second admin dashboard and checkout timeouts look like at the database layer.
The Cleanup
I couldn't just truncate the tables. Pending and in-progress actions are live — they include scheduled subscription renewals, queued emails, and other critical tasks. Deleting those would break the store. The procedure below is what I now run on any site with this problem.
Step 1: Assess What's Safe to Remove
First, check the breakdown by status:
SELECT status, COUNT(*) AS total
FROM wp_actionscheduler_actions
GROUP BY status;
On the 18.4GB site:
+------------+----------+
| status | total |
+------------+----------+
| complete | 19847231 |
| failed | 3142876 |
| canceled | 3698441 |
| pending | 11204 |
| in-progress| 248 |
+------------+----------+
Over 26.6 million rows were safe to delete. Only 11,452 were active. The second site told the same story at smaller scale: 12.7 million completed rows, 102,917 failed, and only 11,326 pending.
Step 2: Batch Delete with WP-CLI
I could run raw SQL, but WP-CLI's action-scheduler clean command properly handles the log table cleanup and respects any hooks other plugins might have registered. The key is increasing the batch size and including failed actions:
screen -S as-cleanup
wp action-scheduler clean \
--status=complete,failed,canceled \
--before='7 days ago' \
--batch-size=1000 \
--batches=0
The --batches=0 flag tells it to keep going until everything matching the criteria is deleted. On the 26-million-row table this took about 45 minutes — I ran it inside screen so an SSH disconnect wouldn't kill the process.
For truly massive tables, WP-CLI can be too slow. In those cases I use batched SQL deletes with a pause between batches. A single DELETE across millions of rows would lock the table for minutes, generate enormous redo logs, and likely take the site down — batching avoids all of that:
#!/bin/bash
# Batched cleanup for extremely large Action Scheduler tables
# Only run this after taking a database backup
BATCH=50000
TOTAL=0
while true; do
DELETED=$(mysql -u root -p"$DB_PASS" "$DB_NAME" -sN -e "
DELETE FROM wp_actionscheduler_actions
WHERE status IN ('complete','failed','canceled')
AND scheduled_date_gmt < DATE_SUB(UTC_TIMESTAMP(), INTERVAL 7 DAY)
LIMIT $BATCH;
SELECT ROW_COUNT();
")
TOTAL=$((TOTAL + DELETED))
echo "Deleted $DELETED rows (total: $TOTAL)"
if [ "$DELETED" -lt "$BATCH" ]; then
break
fi
sleep 2
done
echo "Cleaning orphaned logs..."
mysql -u root -p"$DB_PASS" "$DB_NAME" -e "
DELETE FROM wp_actionscheduler_logs
WHERE action_id NOT IN (
SELECT action_id FROM wp_actionscheduler_actions
);
"
The sleep 2 between batches matters — it gives InnoDB time to flush the redo log and lets active queries complete without excessive lock contention. The orphaned-logs subquery is slower than a blanket delete but safer: it only removes log entries for actions that no longer exist, preserving logs for anything still pending or in progress. On the second site, each 50,000-row batch took 3-4 seconds and the full run finished in roughly 20 minutes.
Step 3: Reclaim Disk Space
Deleting rows from InnoDB tables doesn't automatically free disk space. The .ibd file stays the same size until you optimise the table:
OPTIMIZE TABLE wp_actionscheduler_actions;
OPTIMIZE TABLE wp_actionscheduler_logs;
This rewrites both tables and reclaims the freed space. It locks the tables while it runs — on the first site it took about 8 minutes per table, so I ran it during a low-traffic window. On the second site the rebuild shrank the two data files from 16GB total down to about 180MB.
Preventing It from Happening Again
Cleaning up once is pointless if the tables just bloat again in a month. I add three filters in the site's mu-plugins directory:
<?php
/**
* Plugin Name: Action Scheduler Cleanup Tuning
* Description: Prevents Action Scheduler table bloat on high-volume WooCommerce stores.
*/
// Reduce retention from 31 days to 3 days.
// Completed actions older than 3 days have no diagnostic value
// on a store where we have proper error logging.
add_filter( 'action_scheduler_retention_period', function () {
return 3 * DAY_IN_SECONDS;
} );
// Increase cleanup batch size from 20 to 500.
// The default of 20 cannot keep pace with a store generating
// thousands of actions per day.
add_filter( 'action_scheduler_cleanup_batch_size', function () {
return 500;
} );
// Include failed actions in automatic cleanup.
// By default, only 'complete' and 'canceled' are purged.
// Failed actions accumulate forever unless you add them here.
add_filter( 'action_scheduler_default_cleaner_statuses', function ( $statuses ) {
$statuses[] = 'failed';
return $statuses;
} );
This goes in wp-content/mu-plugins/ rather than functions.php deliberately. Theme updates and theme switches can wipe functions.php customisations; must-use plugins load before everything else and can't be accidentally deactivated.
Fixing the Root Cause
On both sites, the bloat wasn't just a cleanup problem — something was generating an abnormal number of failed actions. The same query finds it:
SELECT hook, COUNT(*) AS total
FROM wp_actionscheduler_actions
WHERE status = 'failed'
GROUP BY hook
ORDER BY total DESC
LIMIT 10;
On both sites the top offender was woocommerce_deliver_webhook_async. On the first, a webhook URL configured in WooCommerce settings pointed to a staging site that had been decommissioned months ago — over 3 million failed entries. On the second, 89,241 failures traced back to a webhook for an inventory management system that no longer existed; every order triggered a delivery to a URL that returned a connection timeout, and WooCommerce faithfully retried, each retry creating another failed row.
The fix in both cases: remove the dead webhook under WooCommerce > Settings > Advanced > Webhooks. On the first site, the daily action creation rate dropped by roughly 40%.
Monitoring Going Forward
I added a simple check that alerts me if the Action Scheduler tables grow past a threshold. This runs as a server cron job once a day:
#!/bin/bash
THRESHOLD_MB=500
DB_NAME="wp_production"
SIZE=$(mysql -sN -e "
SELECT ROUND(SUM(data_length + index_length) / 1024 / 1024)
FROM information_schema.tables
WHERE table_schema = '$DB_NAME'
AND table_name LIKE '%actionscheduler%';
")
if [ "$SIZE" -gt "$THRESHOLD_MB" ]; then
echo "Action Scheduler tables at ${SIZE}MB (threshold: ${THRESHOLD_MB}MB)" \
| mail -s "WP DB Alert: Action Scheduler bloat on $(hostname)" [email protected]
fi
This kind of check is part of the standard monitoring on my WooCommerce maintenance plans — it's far cheaper to catch a table at 500MB than at 14GB.
The Results
The first site's database dropped from 18.4GB to 4.1GB, and nightly backups started completing in under 2 minutes again.
The second site's turnaround was even more dramatic:
- Database size: 16.2GB down to 820MB
- Admin dashboard load: 30 seconds down to 1.8 seconds
- Checkout completion time: consistently under 3 seconds
- InnoDB buffer pool hit ratio: 74% up to 99.6%
wp_actionscheduler_actionsrows: 12.8 million down to 11,326 (pending only)
Six months later, that table holds steady at around 15,000 rows. The retention filter keeps it in check, and the monitoring cron has never fired an alert.
The Takeaway
If you're running a WooCommerce store with subscriptions, webhooks, or any integration that uses background jobs, check your Action Scheduler table sizes today — run the information_schema query at the top of this post. I've seen this same issue on dozens of sites; it's probably the single most common cause of gradual WooCommerce performance degradation that nobody notices until it's critical.
The defaults — 31-day retention, 20-row batch cleanup, and no failed action purging — are designed for small, low-traffic sites. Once you're past 50 orders a day, the cleanup can't keep pace and the tables grow indefinitely. The signs are usually indirect: backups that take too long or fail entirely, slow admin pages (especially WooCommerce > Status > Scheduled Actions), checkout timeouts, and disk usage that keeps climbing without matching content growth. A 3-line mu-plugins file is all it takes to prevent it — and if your database is already misbehaving, that's exactly the sort of problem my database optimisation service exists for.
Stop Firefighting. Start Maintaining.
I manage 70+ WordPress sites for UK agencies and businesses. Whether you need ongoing maintenance, emergency support, or a one-off performance fix — I can help.
