How a 12-Million-Row Action Scheduler Table Brought a WooCommerce Store to Its Knees

· 9 min read

A client's WooCommerce store — around 200 orders per day across two storefronts — had been getting progressively slower over six months. By the time they reached out, the admin dashboard took 30 seconds to load and customers were abandoning checkout because the payment confirmation page would time out.

The First Clue

I SSH'd in and checked the database size:

mysql -e "SELECT table_name, 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 = 'wp_production' ORDER BY data_length DESC LIMIT 10;"

The results were staggering:

+--------------------------------------+------------+---------+----------+
| table_name                           | table_rows | data_mb | index_mb |
+--------------------------------------+------------+---------+----------+
| wp_actionscheduler_actions           |   12847291 | 4812.00 |  3241.00 |
| wp_actionscheduler_logs              |   38519840 | 6105.00 |  2890.00 |
| wp_posts                             |     284610 |  312.00 |    48.00 |
| wp_postmeta                          |    1205847 |  245.00 |   189.00 |
| wp_options                           |       4127 |   42.00 |     3.00 |
+--------------------------------------+------------+---------+----------+

The wp_actionscheduler_actions table had 12.8 million rows consuming nearly 5GB of data plus another 3.2GB of indexes. The logs table was even worse — 38.5 million rows, over 6GB. Together, these two tables accounted for 94% of the entire database.

Understanding the Action Scheduler

WooCommerce's Action Scheduler is a background job queue. Every order triggers multiple scheduled actions: analytics updates, email notifications, stock adjustments, webhook deliveries. On a busy store, that's 8-12 actions per order, every single day.

The problem is retention. By default, Action Scheduler keeps completed actions for 30 days before its built-in cleanup removes them. The cleanup itself runs as a scheduled action, processing just 20 rows per batch. On a store doing 200 orders per day, that's roughly 2,000 new action rows daily — and the default cleanup of 20 rows per run simply cannot keep pace.

I checked what was actually in the table:

SELECT status, COUNT(*) as total
FROM wp_actionscheduler_actions
GROUP BY status
ORDER BY total DESC;
+-----------+----------+
| status    | total    |
+-----------+----------+
| complete  | 12701844 |
| failed    |   102917 |
| canceled  |    31204 |
| pending   |    11326 |
+-----------+----------+

99% of the rows were completed actions that should have been cleaned up months ago. The cleanup process itself was likely timing out or getting stuck behind the very bloat it was trying to clear.

Why It Was Killing Performance

Every time WordPress loads the WooCommerce admin, the Scheduled Actions page, or processes an order, it queries the wp_actionscheduler_actions table. With 12 million rows, even indexed queries were slow because the InnoDB buffer pool couldn't hold the indexes in memory. The server had 8GB of RAM with innodb_buffer_pool_size set to 2GB — but the Action Scheduler indexes alone needed over 3GB.

I confirmed this by checking the buffer pool hit ratio:

SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_read%';

The hit ratio was 74% — meaning one in four index lookups was hitting disk. For a WordPress database, you want this above 99%.

The Fix: Three-Stage Cleanup

I couldn't just run a single DELETE on 12 million rows. That would lock the table for minutes, generate enormous redo logs, and likely crash the site. Instead, I used a batched approach.

Stage 1: Batched SQL Deletion

I wrote a quick bash loop to delete completed actions in batches of 50,000:

while true; do
  DELETED=$(mysql -N -e "DELETE FROM wp_production.wp_actionscheduler_actions WHERE status IN ('complete', 'failed', 'canceled') LIMIT 50000; SELECT ROW_COUNT();")
  echo "$(date): Deleted $DELETED rows"
  if [ "$DELETED" -eq 0 ]; then
    break
  fi
  sleep 2
done

The sleep 2 between batches is important — it gives InnoDB time to flush the redo log and lets active queries complete without excessive lock contention. On this server, each batch took about 3-4 seconds, so the full cleanup ran for roughly 20 minutes.

I ran the same process on the logs table:

while true; do
  DELETED=$(mysql -N -e "DELETE FROM wp_production.wp_actionscheduler_logs WHERE action_id NOT IN (SELECT action_id FROM wp_production.wp_actionscheduler_actions) LIMIT 50000; SELECT ROW_COUNT();")
  echo "$(date): Deleted $DELETED orphaned log rows"
  if [ "$DELETED" -eq 0 ]; then
    break
  fi
  sleep 2
done

This approach with a subquery is slower but safer — it only removes log entries for actions that no longer exist, preserving logs for any remaining pending or in-progress actions.

Stage 2: Reclaim Disk Space

After deleting millions of rows, InnoDB doesn't automatically return disk space to the filesystem. The tablespace still occupies the original size on disk. To reclaim it:

OPTIMIZE TABLE wp_actionscheduler_actions;
OPTIMIZE TABLE wp_actionscheduler_logs;

This rebuilt both tables, shrinking the data files from 16GB total down to about 180MB. On a production site, OPTIMIZE TABLE locks the table, so I ran it during a low-traffic window at 3am.

Stage 3: Prevent It from Happening Again

This is the part most guides skip. Cleaning up once is pointless if the table just bloats again within a few months. I added three filters to the site's mu-plugins/action-scheduler-tuning.php:

<?php
/**
 * Action Scheduler performance tuning.
 * Prevents table bloat on high-volume WooCommerce stores.
 */

// Reduce retention from 30 days to 3 days.
// Completed actions older than 3 days serve no purpose.
add_filter( 'action_scheduler_retention_period', function () {
    return 3 * DAY_IN_SECONDS;
} );

// Increase cleanup batch size from 20 to 200.
// Default of 20 cannot keep pace on stores doing 100+ orders/day.
add_filter( 'action_scheduler_cleanup_batch_size', function () {
    return 200;
} );

// Include failed actions in automatic cleanup.
// By default, only 'complete' and 'canceled' are purged.
add_filter( 'action_scheduler_default_cleaner_statuses', function ( $statuses ) {
    $statuses[] = 'failed';
    return $statuses;
} );

I put this in mu-plugins rather than functions.php deliberately. Theme changes, theme switches, and child theme updates can wipe functions.php customisations. Must-use plugins load before everything else and cannot be accidentally deactivated.

The 102,000 Failed Actions

Before declaring victory, I investigated the 102,917 failed actions. A quick check of the most common failures:

SELECT hook, COUNT(*) as failures
FROM wp_actionscheduler_actions
WHERE status = 'failed'
GROUP BY hook
ORDER BY failures DESC
LIMIT 5;
+------------------------------------------------+----------+
| hook                                           | failures |
+------------------------------------------------+----------+
| woocommerce_deliver_webhook_async              |    89241 |
| action_scheduler_run_queue                     |     8104 |
| wc_admin_process_orders_milestone              |     5572 |
+------------------------------------------------+----------+

89,000 failed webhook deliveries. The client had configured a webhook to an inventory management system that had been decommissioned months ago. Every order was triggering a webhook to a URL that returned a connection timeout, and WooCommerce was faithfully retrying it. Each retry created another failed action row.

I removed the dead webhook from WooCommerce > Settings > Advanced > Webhooks, which immediately stopped the bleeding.

Monitoring Going Forward

I set up a simple WP-CLI cron job to alert if the table starts growing again:

# /etc/cron.d/action-scheduler-monitor
0 6 * * 1 www-data /usr/local/bin/wp --path=/var/www/html eval \
  'global $wpdb; $count = $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->prefix}actionscheduler_actions"); if ($count > 50000) { wp_mail("[email protected]", "Action Scheduler: $count rows", "The action scheduler table has grown beyond 50,000 rows. Investigate."); }' \
  2>/dev/null

The Results

After cleanup, the numbers spoke for themselves:

  • 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_actions rows: 12.8 million down to 11,326 (pending only)

Six months later, the table holds steady at around 15,000 rows. The retention filter keeps it in check, and the weekly monitoring cron has never fired an alert.

Key Takeaways

If you're managing WooCommerce sites, check your Action Scheduler tables today. Run that 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 default Action Scheduler settings are designed for small stores doing a handful of orders per day. Once you're past 50 orders daily, the cleanup process can't keep pace, and the tables will grow indefinitely. A 3-line mu-plugins file is all it takes to prevent it.


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.

View Maintenance Plans Get in Touch

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.

View Maintenance Plans Get in Touch

Get in Touch to Discuss Your Needs