Infrastructure
Cron, Queues and Background Work
WP-Cron is triggered by a page load, not by a clock. What that means for a site with no traffic, and how Action Scheduler's queue actually drains.
A visitor opens a page and waits 900 ms. Inside those 900 ms, WordPress schedules a
loopback HTTP request to wp-cron.php, which may run a plugin update check, a
transient cleanup and a queue of order emails. None of that work is in the visitor's
interest and all of it is in the visitor's latency budget. That is WP-Cron: not a
scheduler, a page load with extra steps.
The reason is honest: WordPress runs on shared hosting where a reliable system cron may not exist, so it uses a page view as a clock. The consequence is that every property people assume about cron is wrong. Nothing runs while nobody is looking. Everything runs while somebody is looking. And the amount of work that arrives at once depends on how long the site was quiet.
The lab box is a WordPress 7.0.7 install with Action Scheduler 4.2.0 and a single-threaded built-in PHP server. Where a number depends on that environment rather than on WordPress, it is labelled as such.
A site with no traffic runs no cron, and a busy site runs it under a waiting request
On the lab install, a single event was scheduled five minutes in the past and left alone. It stayed unexecuted:
wp_schedule_single_event( time() - 300, 'm5_cron_probe' )
next_scheduled remains set after a page request
m5_cron_probe_fired = 0
wp cron event run --due-now -> Executed the cron event 'm5_cron_probe' in 0.004s
m5_cron_probe_fired = 1
The event was due and nothing ran it until something called WP-Cron explicitly. On a live server the page load itself does that, by sending the loopback request that the lab's single-threaded development server cannot answer while it is still serving the page. The lesson does not rest on that detail: the event had been due for five minutes and its only consumer was a request that had not happened yet.
Now put the two halves together. A site with no traffic accumulates due events and runs none of them, so the first visitor after a quiet night triggers all of them at once and pays for the work. A site with steady traffic spreads that work over requests, which means the cost is always paid by a user, and the user with the worst luck is the one whose request arrives when the loopback request takes a worker.
DISABLE_WP_CRON plus a real cron entry separates background work from visitor latency
The fix is two settings. In wp-config.php:
define( 'DISABLE_WP_CRON', true );
define( 'WP_CRON_LOCK_TIMEOUT', 60 ); // seconds; default is 60
Then a system cron entry that runs wp-cron.php on a schedule, with the
site's own PHP binary rather than one a web request happened to use:
*/5 * * * * /usr/bin/php /var/www/site/wp-cron.php >/dev/null 2>&1
# WP-CLI instead of the file, which skips the HTTP layer entirely:
*/5 * * * * cd /var/www/site && /usr/local/bin/wp cron event run --due-now >/dev/null 2>&1
Two details decide whether this actually helps. First, system cron runs commands; it
does not know about WordPress's event list, so a five-minute cron entry running
wp-cron.php is a five-minute granularity floor on every event. Second, the
lock is real: WP_CRON_LOCK_TIMEOUT is the number of seconds WordPress will
refuse to start another cron run, and with a short interval and long jobs the safe
combination is a lock timeout longer than the job. Otherwise the next tick starts a second
run while the first is still working.
pre_schedule_event is the hook to use when one plugin schedules too much
wp_schedule_event( $timestamp, $recurrence, $hook, $args ) creates a
recurring event and wp_schedule_single_event( $timestamp, $hook, $args )
creates a one-off. Both pass through the pre_schedule_event filter, which
lets you refuse the schedule before it is written to the options table. On the lab
install, blocking one hook and allowing another looked like this:
add_filter( 'pre_schedule_event', function ( $pre, $event, $wp_error ) {
if ( 'm5_blocked_probe' === $event->hook ) {
return $wp_error
? new WP_Error( 'm5_blocked', 'this hook is not scheduled on this site' )
: false;
}
return $pre;
}, 10, 3 );
wp_schedule_single_event( time() + 60, 'm5_blocked_probe' ); // false
wp_schedule_single_event( time() + 60, 'm5_allowed_probe' ); // true
wp_schedule_event( time() + 60, 'hourly', 'm5_blocked_probe' ); // false
The filter receives the event as an object with hook,
timestamp, schedule, args and, for recurring
events, interval. Returning false blocks the schedule;
returning a WP_Error blocks it and gives the caller something to log when
$wp_error is true. This is a filter for behaviour you are trying to stop, not
a queue. It runs when the event is scheduled, so it cannot help with an event that is
already in the options table, and it does not make the work go anywhere else.
Action Scheduler is a queue that happens to be driven by WP-Cron
WooCommerce ships Action Scheduler for anything that should not run inside the request that caused it: order emails, subscription renewals, webhook deliveries, stock syncs. In Action Scheduler 4.2.0 the API is a set of plain functions, and the async variant is the one to reach for from a request:
// One action, to run as soon as a queue runner picks it up.
$action_id = as_enqueue_async_action( 'm5_as_job_short', array( $order_id ), 'm5lab' );
// One action at a known time.
as_schedule_single_action( time() + 600, 'm5_as_job_long', array( $order_id ), 'm5lab' );
// A repeating action, every hour, starting from now.
as_schedule_recurring_action( time(), HOUR_IN_SECONDS, 'm5_as_sync', array(), 'm5lab' );
Each call writes a row into wp_actionscheduler_actions and a log row into
wp_actionscheduler_logs. On the lab install, enqueueing 250 actions cost 523
queries and took 0.6 to 1.1 seconds, so treat enqueueing as a database operation: a loop
that enqueues one action per item in a 10,000-row import is 20,000 writes, and it belongs
behind a queue of its own, not inside the import.
The queue runner works in batches, and it is itself driven by WP-Cron: Action Scheduler
registers a custom every_minute schedule and hooks its runner to
action_scheduler_run_queue. That one detail connects the two halves of this
lesson, because a site where WP-Cron does not fire has a queue that does not advance.
ActionScheduler_QueueRunner uses a default batch size of 25, filterable
through action_scheduler_queue_runner_batch_size, and the WP-CLI runner takes
--batch-size. Each batch is reserved with a claim, and the claim is what
makes concurrent runners safe:
250 actions, 10 short jobs per 1 long job (200 ms each)
wp action-scheduler run (batch 100)
3 batches, 250 complete, 5.09s wall
claims recorded: 100 actions, 100 actions, 50 actions
wp action-scheduler run --batch-size=25
10 batches, 250 complete, 6.17s wall
wp action-scheduler run --batch-size=25 --batches=1
claimed 25 actions in one batch (claim id 1), 25 rows marked failed
when the callbacks were not registered in that process
Two operational facts come out of that. Batch size is a throughput and latency knob,
not a correctness one: 25-action batches took 21% longer to drain the same queue, because
each batch pays for its own claim and bookkeeping. And a claim is a lease, not a lock. If
the process dies mid-batch, the actions stay in the running state with that
claim id attached. Action Scheduler's queue cleaner reclaims them, using a 300-second
default that the action_scheduler_timeout_period filter can move, and marks
actions that ran too long as failed after a similar period. None of that protects your
side effects.
A long job has to be idempotent and resumable, because retries are duplicates
The lab registered a deliberately broken callback that writes a counter and then throws. Three actions, three attempts, one side effect each:
enqueue 3 x m5_as_job_crash, then wp action-scheduler run
attempts=1 status=failed for all three rows
m5_as_crash_fired = 3
a later run does not retry them: m5_as_crash_fired stays 3
The failure did not undo the counter write, and the reason that matters is what happens when somebody retries. A failed action sits in the table with its hook and arguments intact, so re-running it runs the callback from the beginning with the same arguments. Anything the callback already did, it does again. A job that creates a shipment label, adds a customer note or charges a card is now a duplicate source of truth.
Design for that before you need it. Make the effect conditional on state you can read back, so a second run finds the work already done and returns. Keep each unit of work small enough to finish inside the timeout, and pass a cursor in the arguments so a large job can enqueue its own continuation action rather than running for ten minutes inside one claim. Record progress in the same place the effect lands, not in a separate counter that can disagree with it. The question to ask of a queued job is not whether it works, but what it does the second time.
The pending queue is visible, and a queue you cannot see is a queue you cannot trust
Two commands answer most questions. The status table shows counts and the oldest row per status, which is how you notice a queue that is growing rather than draining:
wp action-scheduler status
Data store: ActionScheduler_HybridStore
Runner: ActionScheduler_QueueRunner
Version: 4.2.0
status count oldest newest
complete 250 2026-10-16 19:54:31 +0000 2026-10-16 19:54:37 +0000
pending 2 2026-10-16 19:54:47 +0000 2026-10-16 19:55:30 +0000
failed 3 2026-10-16 19:54:42 +0000 2026-10-16 19:54:42 +0000
Then the individual rows, filtered by hook or group, with the attempt count and the last
attempt time. In the admin, WooCommerce adds an Action Scheduler list under Tools, and the
same columns are there. A pending count that only grows, or a set of rows stuck in the
running state with an old claim, is the signature of a runner that is not running: missing
system cron, DISABLE_WP_CRON set without the replacement entry, or a queue
runner killed by a request timeout.
Do this on a staging copy, in three steps.
First, prove the trigger. Schedule a single event one minute in the future with
wp_schedule_single_event(), then leave the site alone and check
wp_next_scheduled() and the event's effects after the time has passed.
Then load a page and check again.
Second, move it. Add DISABLE_WP_CRON to wp-config.php,
schedule another event, and run wp cron event run --due-now from the
command line. Confirm with wp cron event list that the event is gone, and
decide what your real cron entry should be: interval, command, and where the output
goes.
Third, watch a queue. Enqueue twenty actions with
as_enqueue_async_action() under a hook that sleeps for 100 ms and records
its action id into an option, then drain them with
wp action-scheduler run --batch-size=5. Write down the wall time, the
number of batches, and what the option contains.
Then answer: if you ran that drain twice in a row, what would the option contain, and what would your callback have to check to make the second run a no-op?
Show the solution and the reasoning
The first two steps are about the difference between a schedule and a trigger. A
scheduled event is a row in an option named cron; the trigger is a request
to wp-cron.php. Removing the trigger without adding a replacement does not
make cron slower, it stops it, and the symptom is a queue that grows while every page
stays fast. That is the worst kind of failure to diagnose, because the site looks
healthy. Check wp cron event list for events whose "next run" is in the
past and getting older.
For the queue, the drain writes one entry per execution, so twenty actions produce
twenty entries if every action ran once. Run it twice and the honest answer is twenty
more entries, because the actions are gone: a successful run marks them
complete, and the runner does not pick complete rows up again. The duplicate
risk is not re-running a completed action. It is a failed or timed-out action being
retried, or a job that enqueues its own continuation twice.
So the check the callback needs is on the effect, not on the action. A pattern that
works: give the work a natural key, such as the order id plus the step name, and look for
the row that step writes before doing anything. If it exists, log and return. The
alternative pattern, an option named like m5_processed_$id, works until the
option is evicted or the key is wrong, which is why state in the table you already query
is the better choice.
Two readings from the numbers are worth keeping. Batch size changed the drain time by
about a fifth for the same work, so tune it against your own job mix rather than copying
a value. And the failed actions from the lab were not retried automatically: they stayed
failed with attempts=1 until a human or a scheduled retry
touched them. Automatic retry counts are a WooCommerce and plugin decision, not
something Action Scheduler does on its own, so read the code that enqueues your job
before assuming a failure is transient.
Official reference
The WordPress developer reference for what this lesson uses.
Progress
Mark this lesson done and it stays marked in this browser.