WordPress Action Scheduler Limits with High-Volume Stores
If you run a busy WooCommerce store, a lot of what keeps it alive happens quietly in the background. Order emails, renewals, inventory syncs, and webhooks never run on the page the customer sees.
The engine behind all of that is Action Scheduler. It works beautifully at first. But on high-volume stores, it slowly becomes one of the hardest-to-spot bottlenecks in the whole stack.
When that queue backs up, the cost is real: late renewals, missed webhooks, and stale stock that leads to oversells.
This guide shows why Action Scheduler becomes a bottleneck, how to spot it, and the fixes that actually move the needle.
Who Should Read This Guide
It is written for two readers.
If you own the store, you’ll learn the warning signs and the no-code steps you can take today.
If you’re the engineer, you’ll get the queries, snippets, and server changes to fix it for good.
What Action Scheduler Is — and Why Your Store Depends on It
Action Scheduler is a scalable, traceable job queue library that ships inside WooCommerce.
It powers nearly every deferred operation in your store — order emails, inventory syncs, subscription renewals, and webhook deliveries.
Anything scheduled with as_schedule_single_action() or as_schedule_recurring_action() runs through it too.
Under the hood, it is three parts working together:
- A persistent queue backed by custom database tables.
- A scheduler that maps each action to a future timestamp.
- A runner that claims and executes those actions during request cycles.
It uses four custom tables: actionscheduler_actions, actionscheduler_logs, actionscheduler_groups, and actionscheduler_claims.
On a high-volume store, the actions table alone can grow into millions of rows. That growth is where most of the trouble starts.
The Life of a Single Action
Every job moves through a small set of statuses. Once you know them, the rest of this guide reads much easier.
- pending — scheduled and waiting for its time to arrive.
- in-progress — claimed by a runner and executing right now.
- complete — finished successfully.
- failed — threw an error or timed out, and may be retried.
- canceled — unscheduled before it ever ran.
A healthy store moves actions from pending to complete quickly. A bottleneck is simply pending piling up faster than your runners can clear it.
How the Queue Runs — and Why WP-Cron Holds It Back
By default, Action Scheduler piggybacks on WP-Cron. And WP-Cron is not a real cron job — it fires on page load.
That single design choice creates three recurring problems on busy stores.
- Triggered by traffic, not time. WP-Cron only fires when a visitor hits the site. During quiet hours, scheduled actions pile up silently.
- Conservative defaults. Each runner claims a batch of up to 25 actions and keeps processing batches for at most 30 seconds before it stops.
- Contention under concurrency. Many concurrent page requests can each try to start a runner. The claim mechanism stops double-execution, but the database work to coordinate those claims still adds load.
A common misconception is that a cron run processes only one batch.
It doesn’t. The runner loops, claiming batch after batch until it hits the time limit, the memory limit, or runs out of due actions.
Here is a simplified version of that loop, so you can see where the cost lives:
// Simplified from ActionScheduler_QueueRunner — illustrative, not verbatim
public function run( $context = 'WP Cron' ) {
$processed = 0;
// Bail early if too many batches are already running concurrently
if ( $this->has_maximum_concurrent_batches() ) {
return $processed;
}
// Keep claiming and running batches until a limit is hit
do {
$in_batch = $this->do_batch( $this->get_batch_size(), $context );
$processed += $in_batch;
} while ( $in_batch > 0 && ! $this->batch_limits_exceeded( $processed ) );
return $processed;
}
// One batch: claim due actions atomically, then run them
protected function do_batch( $size = 25, $context = '' ) {
$claim = $this->store->stake_claim( $size );
foreach ( $claim->get_actions() as $action_id ) {
$this->process_action( $action_id, $context );
}
$this->store->release_claim( $claim );
return count( $claim->get_actions() );
}
The expensive step is stake_claim().
It runs an UPDATE ... LIMIT that stamps a unique claim_id onto a batch of due, unclaimed rows.
On a healthy table, that update is cheap.
On a bloated table with degraded indexes, it scans far more rows than it claims — blocking other runners that wait their turn.
Why Action Scheduler Becomes a Bottleneck at Scale
High-volume stores rarely hit just one limit.
They hit several at once, and the symptoms compound.
In ecommerce terms, a slow queue is not just a tech problem.
A late renewal is lost revenue, a dropped webhook breaks fulfillment, and a stalled sync causes oversells.
You likely have a queue problem if you recognize any of these signs:
- Order or shipping emails arrive minutes — or hours — late.
- Subscription renewals process well after their due time.
- The admin “Scheduled Actions” screen is slow or times out.
- The pending count keeps climbing and never settles back down.
Three forces drive that degradation. Here is how each one works.
1. The Queue Fills Faster Than It Drains
Every extension that uses Action Scheduler adds to the same queue.
On a store doing 500+ orders a day with subscriptions, memberships, and sync plugins, tens of thousands of pending actions is normal.
A batch of 25 simply cannot drain that fast enough.
2. The Tables Bloat and Slow Every Query
By default, Action Scheduler keeps completed and failed actions for around 30 days before cleanup.
At high volume, the actions and logs tables swell into the millions of rows.
Every query that touches them — including the admin “Scheduled Actions” screen — gets slower.
3. Actions Get Stuck Mid-Run
Slow or memory-leaking callbacks don’t always fail cleanly.
They can exhaust PHP’s memory limit mid-batch and take the whole runner down with them.
When that happens, the actions already claimed in that batch stay marked in-progress with their claim still attached.
Action Scheduler does try to recover on its own.
Its cleaner resets actions stuck past action_scheduler_timeout_period (default 300 seconds), and marks repeat offenders failed after action_scheduler_failure_period.
But recovery only runs when a runner runs.
On a stalled queue, those resets are delayed too — and that gap is where renewals fire late, webhooks drop, and stock updates stall.
What Store Owners Can Do Right Now
You don’t have to be a developer to act on this.
Most of the relief comes from three decisions you can make today.
1. Move the Site to a Real Cron Job
Ask your host to disable WP-Cron and run cron on a fixed schedule instead.
Most managed WooCommerce hosts offer this as a setting or a quick support request.
This removes the “no traffic, no processing” gap and is the single highest-impact change you can make.
2. Install the Official High-Volume Helper
WooCommerce maintains a free, open-source plugin — Action Scheduler – High Volume — that safely raises batch size, concurrency, and time limits.
Have your developer or host install it once.
There are no settings to configure and no custom code for you to maintain.
3. Watch the Queue From Your Dashboard
Open WooCommerce → Status → Scheduled Actions. The Pending tab shows everything waiting to run.
Check it weekly. A queue that drains back down after busy periods is healthy.
A Pending count that only ever grows is your warning sign.
If pending stays high after these steps, hand this article to a developer and continue with the diagnostics and fixes below.
How to Diagnose a Slow Action Scheduler Queue
Before you change anything, get hard numbers.
These queries and snippets give you a clear read on queue health.
One note first: the SQL below assumes the default wp_ table prefix. Swap it for your own if you changed it at install time.
1. Measure Queue Depth and the Oldest Pending Action
-- Current state of the action queue, grouped by status and hook
SELECT
status,
hook,
COUNT(*) AS total,
MIN(scheduled_date_gmt) AS oldest,
MAX(scheduled_date_gmt) AS newest
FROM wp_actionscheduler_actions
GROUP BY status, hook
ORDER BY total DESC;
-- Check the claim query's plan.
-- If "type" in the EXPLAIN output shows "ALL", it's doing a full table scan.
EXPLAIN SELECT action_id
FROM wp_actionscheduler_actions
WHERE claim_id = 0
AND status = 'pending'
AND scheduled_date_gmt <= UTC_TIMESTAMP()
ORDER BY scheduled_date_gmt ASC
LIMIT 25;
2. Queue Lag Report in PHP
This logs counts by status and the lag of the oldest pending action.
Lag is the real health signal — a deep queue that still drains on time is fine.
Treat this as a temporary diagnostic. It runs count queries on every request.
Capture your baseline, then remove it — don’t leave per-request queries running on a busy store.
<?php
/**
* Logs queue health on shutdown.
* Add to: /wp-content/mu-plugins/as-diagnostics.php
*/
function store_queue_health_report() {
if ( ! class_exists( 'ActionScheduler' ) ) {
return;
}
global $wpdb;
$store = ActionScheduler::store();
$statuses = [ 'pending', 'in-progress', 'failed', 'complete' ];
// query_actions() returns a count when 'count' is passed as the query type
foreach ( $statuses as $status ) {
$count = $store->query_actions( [ 'status' => $status ], 'count' );
error_log( "AS Health [{$status}]: {$count} actions" );
}
// Lag = now minus the oldest pending scheduled time
$oldest = $wpdb->get_var(
"SELECT MIN(scheduled_date_gmt)
FROM {$wpdb->prefix}actionscheduler_actions
WHERE status = 'pending'"
);
if ( $oldest ) {
$lag_sec = time() - strtotime( $oldest . ' UTC' );
error_log( "AS Health: oldest pending lag = {$lag_sec}s" );
}
}
add_action( 'shutdown', 'store_queue_health_report' );
3. Find and Reset Stuck In-Progress Actions
Use this only as a manual fallback when the built-in cleaner can’t run because the queue itself is stalled.
-- Actions stuck "in-progress" for over 10 minutes are suspect
SELECT
a.action_id,
a.hook,
a.status,
a.last_attempt_gmt,
TIMESTAMPDIFF(MINUTE, a.last_attempt_gmt, UTC_TIMESTAMP()) AS minutes_stuck,
a.args
FROM wp_actionscheduler_actions a
WHERE a.status = 'in-progress'
AND a.last_attempt_gmt < DATE_SUB(UTC_TIMESTAMP(), INTERVAL 10 MINUTE)
ORDER BY minutes_stuck DESC
LIMIT 50;
-- Reset them to pending and drop the claim so a runner can retry
UPDATE wp_actionscheduler_actions
SET status = 'pending',
claim_id = 0
WHERE status = 'in-progress'
AND last_attempt_gmt < DATE_SUB(UTC_TIMESTAMP(), INTERVAL 10 MINUTE);
Developer Fixes That Restore Throughput
The rest of this guide is the engineer’s track.
Each fix is a small snippet you can drop into an mu-plugin, plus the server and database changes that back it up.
Fix 1 — Increase Batch Size and Concurrency
Action Scheduler exposes filters for batch size, concurrency, and the time limit.
The defaults are deliberately safe, not fast.
The official action-scheduler-high-volume plugin sets sensible values: batch size ×4, concurrent batches ×2, and a 120-second time limit.
The snippet below mirrors that.
<?php
/**
* Scale up Action Scheduler throughput.
* Add to: /wp-content/mu-plugins/as-tuning.php
*
* Rule of thumb: batch_size x avg_action_ms < (time_limit x 0.8 x 1000)
*/
// Actions per batch (default: 25)
add_filter( 'action_scheduler_queue_runner_batch_size', function() {
return 100;
});
// Max concurrent batches (default: 5). Raising this increases server load —
// scale it to your CPU and DB headroom, and watch load after each change.
add_filter( 'action_scheduler_queue_runner_concurrent_batches', function() {
return 10;
});
// Time limit per runner in seconds (default: 30).
// Keep PHP max_execution_time comfortably above this value.
add_filter( 'action_scheduler_queue_runner_time_limit', function() {
return 120;
});
Note that concurrency does not require system cron.
Action Scheduler can fan out work through its async (loopback HTTP) runner even on WP-Cron — system cron just makes it predictable.
Fix 2 — Reduce Log Retention and Purge in Chunks
<?php
/**
* Trim retention from ~30 days to 7 days.
* Add to: /wp-content/mu-plugins/as-cleanup.php
*/
add_filter( 'action_scheduler_retention_period', function() {
return WEEK_IN_SECONDS;
});
/**
* One-time bulk purge of old finished actions.
* Run via WP-CLI or a custom admin trigger — NOT on every request.
* Chunked deletes keep table locks short.
*/
function purge_old_as_actions() {
global $wpdb;
$cutoff = gmdate( 'Y-m-d H:i:s', strtotime( '-7 days' ) );
do {
$deleted = $wpdb->query( $wpdb->prepare(
"DELETE FROM {$wpdb->prefix}actionscheduler_actions
WHERE status IN ('complete','failed','canceled')
AND scheduled_date_gmt < %s
LIMIT 500",
$cutoff
));
} while ( $deleted === 500 );
}
For routine cleanup, prefer the built-in WP-CLI clean command shown later — it removes orphaned logs too, not just the action rows.
Fix 3 — Deduplicate Before Scheduling
A big source of bloat is code that schedules an action without checking whether one is already pending.
WooCommerce core guards against this — many third-party plugins do not.
<?php
/**
* Safe single-action scheduler — avoids duplicate pending actions.
*
* @param string $hook Action hook name.
* @param array $args Arguments passed to the action.
* @param int $delay Seconds from now to run.
* @return int|false Action ID, or false if one is already scheduled.
*/
function schedule_once( $hook, $args = [], $delay = 0 ) {
if ( as_has_scheduled_action( $hook, $args ) ) {
return false; // Already pending — skip
}
return as_schedule_single_action(
time() + $delay,
$hook,
$args,
'my-plugin-group'
);
}
// Usage: a single, de-duplicated inventory sync per order
add_action( 'woocommerce_order_status_processing', function( $order_id ) {
schedule_once(
'sync_inventory_for_order',
[ 'order_id' => $order_id ],
30
);
});
Fix 4 — Keep the Indexes Healthy
Past a few million rows, MySQL’s planner can misjudge the table and fall back to a full scan on the claim query.
Refresh statistics and confirm the composite claim index is present.
Run these with wp db query < fix-indexes.sql.
-- Refresh planner statistics
ANALYZE TABLE wp_actionscheduler_actions;
-- Rebuild the table to defragment indexes (online on MySQL 5.7+ / MariaDB 10.3+)
ALTER TABLE wp_actionscheduler_actions ENGINE=InnoDB;
-- Action Scheduler ships this composite index for the claim query.
-- Confirm it exists:
SHOW INDEX FROM wp_actionscheduler_actions
WHERE Key_name = 'claim_id_status_scheduled_date_gmt';
-- If it is somehow missing, recreate it
CREATE INDEX claim_id_status_scheduled_date_gmt
ON wp_actionscheduler_actions (claim_id, status, scheduled_date_gmt);
Architectural Upgrades for High-Volume Stores
Replace WP-Cron with a Real System Cron
This is the single highest-impact change.
Stop firing cron on page loads and run it on a fixed schedule instead.
Step 1 — Disable WP-Cron’s HTTP trigger in wp-config.php:
define( 'DISABLE_WP_CRON', true );
Step 2 — Add a real cron entry with crontab -e:
# Run all due WP-Cron events every minute * * * * * /usr/local/bin/wp --path=/var/www/html cron event run --due-now --quiet >> /var/log/wpcron.log 2>&1 # Drive the Action Scheduler queue directly for higher throughput. # --force overrides the concurrent-batch limit, so keep this to a single # entry to avoid overlapping runs piling onto the database. * * * * * /usr/local/bin/wp --path=/var/www/html action-scheduler run --force >> /var/log/as-runner.log 2>&1
WP-CLI Commands for Queue Management
Running the queue from the command line avoids the constraints of a normal web request, which makes it the better tool for large queues.
# Overall queue status (counts by status) wp action-scheduler status # Run the queue now — useful after a cron gap. --force ignores concurrency limits. wp action-scheduler run --force # Clean up old actions. Defaults to 'complete' and 'canceled' older than ~31 days. wp action-scheduler clean --batch-size=200 --pause=1 # Target a specific status and age, in chunks, pausing between batches wp action-scheduler clean --status=failed --batch-size=100 --before='30 days ago' --pause=2
There is no top-level cancel or delete command — pruning is done through clean.
To cancel specific actions, use as_unschedule_all_actions( $hook ) in code.
Monitor the Queue So It Never Surprises You Again
Reactive debugging is expensive.
The monitor below schedules itself and alerts Slack when a threshold is crossed — before customers notice.
<?php
/**
* Self-scheduling queue health monitor.
* Runs every 5 minutes via Action Scheduler.
* Add to: /wp-content/mu-plugins/as-monitor.php
*/
add_action( 'init', function() {
if ( ! function_exists( 'as_has_scheduled_action' ) ) {
return;
}
if ( ! as_has_scheduled_action( 'monitor_as_queue_health' ) ) {
as_schedule_recurring_action(
time(),
5 * MINUTE_IN_SECONDS,
'monitor_as_queue_health',
[],
'monitoring'
);
}
});
add_action( 'monitor_as_queue_health', function() {
$store = ActionScheduler::store();
$thresholds = [
'pending_max' => 10000,
'in_progress_max' => 50,
'failed_max' => 100,
];
// Pass 'count' as the query type to get totals, not row data
$pending = $store->query_actions( [ 'status' => 'pending' ], 'count' );
$in_progress = $store->query_actions( [ 'status' => 'in-progress' ], 'count' );
$failed = $store->query_actions( [ 'status' => 'failed' ], 'count' );
$alerts = [];
if ( $pending > $thresholds['pending_max'] ) {
$alerts[] = "Pending queue: {$pending} actions";
}
if ( $in_progress > $thresholds['in_progress_max'] ) {
$alerts[] = "Stuck in-progress: {$in_progress} actions";
}
if ( $failed > $thresholds['failed_max'] ) {
$alerts[] = "Failed actions: {$failed}";
}
if ( ! empty( $alerts ) ) {
$webhook = get_option( 'slack_webhook_url' );
if ( ! $webhook ) {
return;
}
wp_remote_post( $webhook, [
'body' => wp_json_encode( [
'text' => '*Action Scheduler Alert* | ' . get_bloginfo( 'name' )
. "\n" . implode( "\n", $alerts ),
] ),
'headers' => [ 'Content-Type' => 'application/json' ],
]);
}
});
Where to Start — Impact vs. Effort
You don’t need to apply everything at once.
Work top to bottom — the high-impact, low-effort wins come first.
| Optimization | Expected Impact | Effort |
|---|---|---|
| Real system cron | High — removes traffic-driven timing gaps | Low |
| Increase batch size | High — roughly 4x throughput per run | Low |
| Reduce log retention | High — large table-size reduction | Low |
| Index maintenance | Medium — restores claim-query efficiency | Medium |
| Deduplicate scheduling | Medium — stops re-accumulation | Medium |
| Concurrent runners | High — near-linear scaling, watch server load | Medium |
Conclusion
Action Scheduler is solid engineering, but it was designed for moderate workloads rather than stores processing thousands of orders daily. Its biggest risk is that performance issues develop gradually instead of causing obvious failures.
As delays accumulate, renewals can run late and pending actions can grow rapidly. Fixing the bottleneck requires a layered approach with queue optimization, database maintenance, and ongoing monitoring.