The Problem
I picked up a Friday-night ticket from a subscription store on WooCommerce 10.4: renewals had stopped firing for four days, the order emails were silent, and the merchant only noticed because Stripe started flagging missed renewal attempts. I opened WooCommerce → Status → Scheduled Actions and saw the same pattern I have hit on three client sites this quarter: dozens of actions sitting on in-progress for hours, some for days. Nothing failed, nothing logged, they just never completed.
If your Action Scheduler queue looks like this, with wc_run_payment_complete_actions, woocommerce_send_email, or action_scheduler/migration_hook stuck in-progress and the "Started" timestamp older than your max execution time, you are not alone. This breaks payment retries, abandoned cart emails, subscription renewals, and the HPOS sync queue. Here is what is actually happening and how I fix it.
Why It Happens
Action Scheduler claims a batch of actions, marks them in-progress, runs them inside an ActionScheduler_QueueRunner, and on completion flips the status to complete or failed. The bug is the gap between "claim" and "complete": if the PHP worker dies during execution, the status never updates and the claim ID hangs around forever.
Three things kill the worker mid-run, and I see them in this order on real client sites:
- PHP
max_execution_timeruns out. Default is 30s on most managed WordPress hosts. A single Action Scheduler batch processes 25 actions by default, so one slow webhook or a Mailchimp sync touching 10k records will blow past the limit. PHP kills the process, the lock stays. DISABLE_WP_CRONis true with no real cron set. I see this constantly after migrations. The host turns offwp-cron.php, the migration plan said "we will set up a system cron", nobody ever did, and Action Scheduler depends onwp_loadedplus the async request loopback to keep the queue moving. With both gone, claimed actions sit forever.- Two workers grab the same claim. WooCommerce 9.x changed claim handling to use
GET_LOCK()advisory locks on MySQL, but on hosts without MySQL lock support (some Aurora configs, some MariaDB Galera clusters), the lock silently no-ops. Both workers think they own the claim, one finishes, the other's status update collides and the row is orphaned.
The HPOS migration in 9.4+ made this worse because the migration scheduler pushes thousands of actions into the queue at once. One stuck claim and the whole queue stalls.
The Fix
Step 1: Release the stuck claims. Do not just delete the rows, that breaks idempotency for some recurring actions. Reset status to pending and clear the claim:
wp eval '
global $wpdb;
$cutoff = gmdate( "Y-m-d H:i:s", time() - 600 );
$rows = $wpdb->query( $wpdb->prepare(
"UPDATE {$wpdb->prefix}actionscheduler_actions
SET status = %s, claim_id = 0, last_attempt_gmt = NULL
WHERE status = %s AND last_attempt_gmt < %s",
"pending", "in-progress", $cutoff
) );
$wpdb->query( "DELETE c FROM {$wpdb->prefix}actionscheduler_claims c
LEFT JOIN {$wpdb->prefix}actionscheduler_actions a ON a.claim_id = c.claim_id
WHERE a.action_id IS NULL" );
echo "Reset $rows actions and cleaned orphaned claims\n";
'
The last_attempt_gmt < cutoff guard is the safety net: it only releases claims older than 10 minutes, so a worker actually running right now will not get its rug pulled.
Step 2: Set a real cron job. Stop relying on wp-cron.php from page loads. Add a system cron that hits the loopback URL every minute. On most hosts:
* * * * * cd /var/www/html && /usr/bin/php wp-cron.php > /dev/null 2>&1
Then in wp-config.php:
define( 'DISABLE_WP_CRON', true );
define( 'ALTERNATE_WP_CRON', false );
If your host runs WP-Cron through their control panel (Kinsta, WP Engine, Pressable), confirm the interval is 1 minute, not 15. Action Scheduler's batches assume frequent ticks.
Step 3: Raise the per-batch timeout where it matters. Do not bump the global PHP limit, that hides the problem. Raise it only for Action Scheduler's runner:
add_filter( 'action_scheduler_queue_runner_time_limit', function () {
return 120; // seconds
} );
add_filter( 'action_scheduler_queue_runner_batch_size', function () {
return 10; // smaller batches finish before timeout
} );
Smaller batches matter more than a longer timeout. Ten actions at 5s each finishes inside any host's limit, twenty-five does not.
Step 4: Stop dueling workers. If your host runs PHP-FPM with multiple workers and you are seeing the orphaned-claim symptom, gate the runner to a single instance with a transient lock:
add_action( 'action_scheduler_before_process_queue', function () {
if ( get_transient( 'qc_as_runner_lock' ) ) {
// Another worker is already processing, exit early.
wp_die( '', '', [ 'response' => 200 ] );
}
set_transient( 'qc_as_runner_lock', 1, 90 );
} );
add_action( 'action_scheduler_after_process_queue', function () {
delete_transient( 'qc_as_runner_lock' );
} );
Step 5: Watch the queue. Install no extra plugin, just check Action Scheduler's own admin screen daily for the first week. Filter by status In-progress and sort by Started descending. Anything older than 5 minutes is a stuck claim and you have regressed. The Action Scheduler documentation covers the underlying claim model in detail if you want to dig deeper.
The Lesson
Stuck Action Scheduler tasks are almost never an Action Scheduler bug. It is a PHP timeout, a missing cron, or a worker collision, and the fix is the same three moves: release the orphans, give the runner a real schedule, and constrain batch size so workers finish.
If your store is silently dropping renewals or transactional emails because of stalled background jobs, I do this kind of WooCommerce stabilisation work — see my services. For another silent-failure pattern I covered, look at WooCommerce subscription renewals failing silently.