Action Scheduler Stuck in WooCommerce: Causes and Fixes

Action Scheduler Stuck in WooCommerce: Causes and Fixes

An Action Scheduler stuck on thousands of pending jobs is almost never a bug in Action Scheduler. It is a site where nothing reliable is triggering the queue runner, and the pile-up you are looking at under WooCommerce → Status → Scheduled Actions is the symptom rather than the fault. Fix the trigger and the backlog drains on its own.

This matters more than it sounds. Action Scheduler is the background job queue that WooCommerce and dozens of other plugins run on — order emails, subscription renewals, stock syncs, analytics imports, the HPOS backfill. When the queue stalls, none of that happens, and nothing errors loudly enough to page you. Customers just stop receiving emails.

Where to look before you change anything

The queue lives at WooCommerce → Status → Scheduled Actions, or Tools → Scheduled Actions on a site without WooCommerce. Every plugin that uses the library shares that one queue, so the counts you see are the total across all of them. Note the number next to Pending and the oldest scheduled date before you start — those two figures tell you whether you have a stalled queue or just a busy one.

What a stalled queue actually looks like

The Scheduled Actions screen splits everything by status. Reading those counts correctly saves you from fixing the wrong thing, because a thousand pending actions scheduled for next month is a healthy queue and twelve pending actions from last Tuesday is a dead one.

StatusWhat it meansWhat a large number here is telling you
PendingScheduled, not yet runCheck the dates, not the count. Anything past due by more than a day means the runner is not firing
In-progressClaimed by a runner and currently executingMore than a handful at once, or ones that sit here for minutes, means batches are dying mid-run
CompleteRan without throwingOnly a storage concern. These are purged on a schedule — see the retention section below
FailedThrew an exception, or was still in-progress past the timeoutOpen the row and read its log. This is the only status that tells you why
CanceledUnscheduled before it ranNormal. Recurring actions cancel and reschedule constantly
Statuses as shown on the Scheduled Actions administration screen in Action Scheduler 3.0 and later.

The library’s own documentation is blunt about the pending column: some past-due actions are normal because of how WP-Cron works, but several past-due by more than a day means something is wrong with the site. That is the line to judge yourself against.

Why your Action Scheduler is stuck on pending actions

There are six causes worth checking, and in my experience the first two account for most stalled queues on shared and managed hosting.

WP-Cron never fires. Action Scheduler hangs its queue runner on the action_scheduler_run_queue hook, which WP-Cron triggers at most once a minute. WP-Cron only runs when somebody loads a page. A low-traffic store at 3am has no visitors, so it has no cron, so it has no queue runner. Worse: if someone set DISABLE_WP_CRON to true and never added a real system cron job, the queue has no trigger at all, ever.
Loopback requests are blocked. Once started, the runner keeps itself going by firing asynchronous loopback requests — the site calling its own URL over HTTP. A security plugin, HTTP basic auth on a staging site, an unresolvable hostname, or a firewall that drops traffic from the server to itself will kill those silently. The queue then processes exactly one batch per cron trigger instead of running continuously.
The queue fills faster than 30 seconds a minute can drain it. The runner defaults to a 30-second time limit per request and 25 actions per claimed batch. If your actions each take two seconds, that is roughly fifteen per minute. A plugin queueing a thousand jobs at once will take over an hour, and if it queues a thousand every hour you never catch up.
One action is fatalling and taking the batch with it. A PHP fatal inside a callback kills the whole request, so the other actions claimed in that batch stay in-progress with nobody running them. You see this as a queue that advances a little, stalls, advances a little.
Concurrency is capped at one. Action Scheduler allows a single concurrent batch by default, deliberately, to avoid eating the web server’s PHP workers. That is the right default and the wrong one for a store clearing a backlog.
The tables are big enough to be slow. Claiming a batch is a database write against wp_actionscheduler_actions and wp_actionscheduler_claims. On a table with millions of rows, on a host with a small MySQL buffer pool, claiming becomes slow enough to eat the time budget before any work happens.

Diagnose it in four commands

If you have SSH and WP-CLI, you can separate “cron is broken” from “the queue is just big” in under a minute. Run these in the WordPress root.

# 1. Counts per status, plus which datastore is in use
wp action-scheduler status

# 2. The oldest pending actions — look at the scheduled dates
wp action-scheduler action list --status=pending --per-page=20

# 3. Can WordPress spawn its own cron at all? This is the loopback test
wp cron test

# 4. Is the queue runner even scheduled?
wp cron event list | grep action_scheduler

Command three is the one that usually breaks the case open. If wp cron test cannot spawn a request, your loopbacks are blocked and no amount of tuning will help until that is fixed. The same check lives in the admin under Tools → Site Health, which flags both failed loopback requests and a missed cron schedule — WordPress Site Health: how to find and fix common problems covers reading that screen properly.

No SSH? Check Site Health for the loopback and cron warnings, then look at whether DISABLE_WP_CRON appears in your wp-config.php. Those two facts cover most of what the commands above tell you.

Fix the trigger: give WP-Cron a real schedule

This is the permanent fix, and it is worth doing on every production store whether or not you currently have a backlog. Stop depending on visitor traffic to run background work.

First, turn off the traffic-driven spawner in wp-config.php, above the “stop editing” line:

<?php
define( 'DISABLE_WP_CRON', true );

Do not stop after that line

Setting DISABLE_WP_CRON without adding a real cron job is the single most common way a site ends up with a permanently frozen queue. The constant does not move cron to the server — it only stops WordPress from running it. If you cannot add the cron job in the same sitting, leave the constant out.

Then add a system cron job. Run crontab -e as the user that owns the site files and add one line, adjusting the paths:

# Run due events every minute, silently
* * * * * cd /var/www/example.com && /usr/local/bin/wp cron event run --due-now --quiet > /dev/null 2>&1

Use which wp to get the real binary path — cron does not inherit your shell’s PATH, and a bare wp is the usual reason a crontab line does nothing. If your host has no shell access, most managed hosts expose a cron panel that can hit https://example.com/wp-cron.php?doing_wp_cron on a schedule instead. That is less efficient than the CLI route but far better than nothing.

Scheduled posts failing to publish is the same underlying problem wearing a different hat; if you have seen that too, the missed schedule error in WordPress walks through the cron side in more detail.

Clear an existing backlog with WP-CLI

Fixing the trigger stops the bleeding. It does not clear what has already piled up, and at one batch a minute a large backlog can take days. Run the queue directly instead — it bypasses the web server entirely, so it does not compete with shoppers for PHP workers.

# Run everything due, 100 per batch, until the queue is empty
wp action-scheduler run

# Bigger batches, and override the concurrency guard
wp action-scheduler run --batch-size=200 --force

# Only one plugin's queue — use the value from the Group column
wp action-scheduler run --group=my-plugin-group --batches=10

# Everything except one noisy group
wp action-scheduler run --exclude-groups=my-plugin-group

Two things to know. The CLI runner defaults to 100 actions per batch, which is deliberately larger than the 25 the web runner claims. And --force exists precisely because the concurrency guard will otherwise refuse to start a second runner while a web batch is in flight — useful when a crashed batch has left a stale claim behind.

On a backlog of any size, run it inside screen or tmux. An SSH disconnect halfway through a six-hour drain is a bad afternoon. Watch progress from a second session with wp action-scheduler status.

Tuning the runner: three filters and their real defaults

If the queue keeps outpacing the runner even with cron working, these are the three knobs. Raise them carefully and one at a time, because each one trades throughput for load on a web server that also has customers on it.

FilterDefaultWhat raising it buys youWhat it costs
action_scheduler_queue_runner_time_limit30 secondsMore actions processed per requestMust stay below your PHP max_execution_time and the host’s request timeout. Published host ceilings: WP Engine 60s, Pantheon 120s, SiteGround 120s
action_scheduler_queue_runner_batch_size25 actionsFewer claim queries per action processedOnly helps when actions finish fast. With slow actions you just claim rows you will not reach before the time limit
action_scheduler_queue_runner_concurrent_batches1 batchParallel runners, so throughput scalesThe highest-risk of the three. Each batch is another loopback request and another PHP worker. The library’s own docs warn this “can substantially increase server load and take down a site”
Defaults per Action Scheduler’s published performance documentation. Host time limits are the figures that documentation cites.

Put the overrides in a small must-use plugin rather than your theme’s functions.php, so a theme switch cannot silently revert them:

<?php
// wp-content/mu-plugins/action-scheduler-tuning.php

add_filter( 'action_scheduler_queue_runner_time_limit', function () {
	return 90; // seconds — confirm your max_execution_time allows it
} );

add_filter( 'action_scheduler_queue_runner_batch_size', function () {
	return 50; // default is 25
} );

Before you raise concurrency, be honest about whether you have the headroom. Extra batches consume PHP workers that would otherwise be serving checkout requests, and on a plan with four workers, three background batches means one worker left for the store. WooCommerce hosting requirements: PHP workers and RAM covers how to work out what you actually have. If the admin is already sluggish, raising concurrency will make it worse — start with diagnosing the slow admin dashboard instead.

The better answer for sustained high volume is not more loopbacks. It is a dedicated WP-CLI runner on a cron schedule, which is what the library’s own performance guidance recommends.

Actions stuck in-progress, then suddenly failed

This is a specific and frequently misread pattern. An action goes in-progress, sits there, and five minutes later flips to failed with a message along the lines of “unknown error occurred — check server, PHP and database error logs to diagnose cause”.

Nothing mysterious is happening. Action Scheduler’s queue cleaner marks any action that has been running longer than a timeout as failed, on the assumption that an uncatchable fatal error killed the process. That timeout is 300 seconds by default and is filterable:

<?php
// Give genuinely long-running jobs more rope — or pass a negative
// value to disable the check entirely (rarely a good idea).
add_filter( 'action_scheduler_failure_period', function ( $seconds ) {
	return 600;
} );

So the five-minute delay is the cleaner noticing, not the moment of failure. The action died earlier, and it died in a way PHP could not log through WordPress. Three usual suspects, in order of likelihood:

  • A PHP fatal or memory exhaustion in the callback. The request dies before anything can be written to the action’s log. Your server’s PHP error log has it — enabling WordPress debugging and safely reading error logs covers getting at it without exposing notices to visitors, and the allowed memory size exhausted error covers the memory case specifically.
  • The host killed the request. If your actions routinely run longer than the time limit, and the time limit is above the host’s own request timeout, the process is terminated mid-action. Lower the Action Scheduler time limit to sit under the host’s ceiling rather than above it.
  • A database deadlock while claiming. Two runners contending on the claims table under load. Reducing concurrency, not increasing it, is the fix here.

When a single hook is responsible, the Scheduled Actions screen will show it — sort by hook and the failures cluster. That gives you the plugin. From there it is an ordinary plugin problem, and troubleshooting a WordPress plugin conflict step by step applies unchanged.

Table bloat and what gets cleaned up automatically

Action Scheduler 3.0 and later stores everything in four custom tables rather than in wp_posts: wp_actionscheduler_actions, wp_actionscheduler_claims, wp_actionscheduler_groups and wp_actionscheduler_logs. The logs table is usually the largest, because every status change on every action writes a row.

There is automatic cleanup, and the real defaults are worth knowing because they are longer than most people assume:

FilterDefaultApplies to
action_scheduler_retention_period2,678,400 seconds — 31 daysComplete and canceled actions
action_scheduler_retention_period_for_failedThree times the above — roughly 93 daysFailed actions, kept longer so you can still investigate them
action_scheduler_cleanup_batch_size20 actions per status, per runHow much the cleaner deletes at a time
Values read from ActionScheduler_QueueCleaner in the library’s current source. Automatic cleanup of failed actions arrived in Action Scheduler 4.0.

Notice the batch size: 20 per status per run. If you generated two million rows during an import, the automatic cleaner will not meaningfully dent it. Do that job explicitly:

# Delete complete and canceled actions older than seven days,
# 500 at a time, pausing a second between batches to spare MySQL
wp action-scheduler clean --status=complete,canceled --before='7 days ago' --batch-size=500 --pause=1

Back up before you delete rows, and never truncate the tables

Plenty of advice online suggests truncating the Action Scheduler tables to clear a backlog. Do not. Pending actions include subscription renewals, scheduled emails and partly finished migrations — deleting them does not cancel the work, it loses it, and the plugins that queued them will not notice or reschedule. Use clean with an explicit --status so pending and in-progress rows are never touched, and take a database backup first.

The --pause flag matters more than it looks on a shared database. Deleting half a million rows without it will spike I/O and can slow the whole store for the duration.

Where the queue is a symptom of something else

Order emails not arriving. If emails are queued through Action Scheduler, a stalled queue looks exactly like a mail delivery problem. Check the queue first, then the mail path — WordPress emails not sending covers the second half once you have ruled out the first.

An HPOS migration that never finishes. The order backfill runs through Action Scheduler at 25 orders per batch, so a frozen queue means a migration that sits at the same count for days. Run it from the CLI instead; WooCommerce HPOS migration: risks and real gains has the full sequence and the verification steps.

A queue that grows no matter what you do. At some point the answer is not tuning. A plugin scheduling tens of thousands of actions an hour on four PHP workers is a capacity problem, and the honest fix is either fewer actions or a bigger box.

A ten-minute recovery sequence

  1. Take a database backup. Everything below either deletes rows or runs code you have not run before.
  2. Run wp action-scheduler status and note the pending count and the oldest date.
  3. Run wp cron test. If it fails, fix loopbacks before anything else — usually a security plugin rule or basic auth.
  4. Check wp-config.php for DISABLE_WP_CRON. If it is true, confirm a system cron job actually exists.
  5. Add the per-minute wp cron event run --due-now crontab line. This is the permanent fix.
  6. Drain the backlog with wp action-scheduler run --batch-size=200 --force, inside screen.
  7. Filter the screen to Failed, sort by hook, and read the log on the most common one. That is your actual bug.
  8. Once the queue is current, clean old complete and canceled rows with an explicit --status and a --pause.

Step five is the one that stops this recurring. Everything else on the list is cleanup you will be doing again in three months if you skip it.

Frequently asked questions

Is it safe to delete pending scheduled actions?

No, not as a way of clearing a backlog. Pending rows are work that has been promised — subscription renewals, scheduled emails, an unfinished migration. Deleting them does not cancel that work or cause it to be rescheduled; it just means it never happens. Clean complete and canceled rows freely, and leave pending and in-progress alone.

How many past-due actions are normal?

Some are always normal, because WP-Cron only fires on page loads and the runner has a time budget. The library’s own guidance is the useful threshold: several actions past due by more than a day means something is wrong. Judge by the oldest scheduled date, not by the raw count.

Why do my actions sit in-progress for five minutes and then fail?

Because the queue cleaner marks anything running longer than 300 seconds as failed, assuming an uncatchable fatal killed it. The five minutes is the cleaner noticing, not the failure itself. Look in your PHP error log for the real cause, and raise or lower the window with the action_scheduler_failure_period filter if your jobs are legitimately long-running.

Will disabling WP-Cron fix a stuck Action Scheduler queue?

Only if you replace it with a real system cron job in the same sitting. DISABLE_WP_CRON on its own removes the only trigger the queue has, which guarantees a permanently frozen queue rather than an unreliable one. The combination of the constant plus a per-minute wp cron event run --due-now is what you want.

Can I run Action Scheduler without WooCommerce?

Yes. It is a standalone library bundled by many plugins, and on a site without WooCommerce its screen appears under Tools → Scheduled Actions. Whichever plugin loads the newest copy provides it for everyone, so the queue and the tables are shared across every plugin using it.

Should I increase concurrent batches to clear a backlog faster?

It is the last thing to try, not the first. Each extra batch is another loopback request taking a PHP worker that could be serving a customer, and the library’s own documentation warns it can take a site down. Run wp action-scheduler run over SSH instead — it gets you more throughput with none of that risk, because it never touches the web server.

How large should the Action Scheduler tables be?

There is no fixed number, but if wp_actionscheduler_logs is in the millions of rows on a modest store, cleanup has not been keeping up. Complete and canceled actions are kept 31 days by default and failed ones about three times that, so anything much older than those windows points at a cleaner that is not running — which is usually the same stalled-cron problem as the backlog itself.

Sources

Leave a Reply to This Post