Monitoring WooCommerce Action Scheduler and WP-Cron: Oh Dear vs custom scripts
Disclosure: some links in this guide are affiliate links. If you sign up through them, I may earn a commission at no extra cost to you. How this site makes money.
A WooCommerce store does a lot of work in the background: subscription renewals, webhook deliveries, emails, stock syncs, abandoned-cart reminders and its own cleanup jobs. When those queues stop, nothing looks wrong on the storefront. The first sign is usually a customer asking why they were charged without a confirmation email, or a supplier asking why no orders arrived since Tuesday. This guide sets up monitoring that notices first: a heartbeat for the job runner and a health check for the queues behind it.
We read WordPress 7.1.3's wp-cron.php and the Action Scheduler 4.1.0 that ships with WooCommerce 11.2.1, then broke a real store on purpose. The health endpoint and the cron script ran against that store, with Oh Dear's ping endpoint replaced by a local mock that recorded every ping. A validator checked each health response against Oh Dear's documented format. All 33 checks pass. The store ran on PHP's built-in server and MariaDB on Windows, so the timings are for comparison only. We didn't connect a real Oh Dear account; its behavior comes from its documentation (October 2026).
1. The short answer
| Question | Answer |
|---|---|
| Who runs the jobs? | The system cron, every minute: wp cron event run --due-now, then wp action-scheduler run, each with a time limit |
| What tells you the runner stopped? | An Oh Dear scheduled-task monitor. The script pings it after every run; a missing ping raises an alert |
| What tells you a run crashed? | The same ping carries the exit code. Anything but 0 is a failed run |
| What tells you the queues are stuck? | A health endpoint that Oh Dear polls every minute: WP-Cron lag, past-due actions, stuck actions, failures, missing recurring jobs |
| What it adds to the store | One mu-plugin with three indexed queries (2.4 ms on a 1.06 million-row actions table in our test) and one shell script |
| Cost | Oh Dear from €15 a month for 2 sites; every plan includes cron and application health monitoring (October 2026) |
2. How WooCommerce runs background work
There are two queues, and in WooCommerce 11 one drives the other.
- WP-Cron isn't a cron. On a page load, WordPress checks whether an event is due and, if so, fires a loopback request to
wp-cron.phpwith a 0.01-second timeout. Adoing_cronlock stops a second run for 60 seconds. No visitors, a full-page cache that answers without PHP, or a blocked loopback request (basic auth on staging, a firewall, DNS) all mean no runs. - Action Scheduler is WooCommerce's job queue, stored in
wp_actionscheduler_actions. WooCommerce 11.2.1 moved its own recurring jobs there: session cleanup, scheduled sales, log cleanup, personal-data cleanup, the GeoIP update and its daily admin job. Webhook deliveries and most extension jobs live there too. - The link between them: Action Scheduler's queue runner is a WP-Cron event,
action_scheduler_run_queue, every minute. When WP-Cron stops, Action Scheduler stops with it.
The runner's defaults, from the Action Scheduler 4.1.0 source: one batch at a time (action_scheduler_queue_runner_concurrent_batches = 1), 25 actions per claim, and 30 seconds per run. An action stuck "in progress" is marked failed after 300 seconds, but only when the next runner starts. WooCommerce's own warning about past-due actions appears in wp-admin only once an action is a full day late.
3. Five ways the queues get stuck
Each of these happened on our test store. None of them shows on the storefront.
- The trigger stops. With
DISABLE_WP_CRONset and no system cron, or with no uncached traffic, due events just wait. Our health check showed WP-Cron 40 minutes behind and named the waiting hook. - The runner can't keep up. We queued 3,000 jobs of 50 ms each. The runner started by one
wp-cron.phprequest finished 482 and stopped at its time limit. The WP-CLI runner finished 985 in a minute. If jobs arrive faster than that, the queue only grows. - A runner dies mid-batch. Its claimed action stays "in progress". Nothing cleans it up until another runner starts, so if the runner is dead, it stays stuck. In our test, the next run marked it failed.
- A recurring job quietly stops. When a recurring action fails, Action Scheduler checks the last 5 runs of that hook. If all 5 failed, it doesn't schedule another one. Ours stopped after exactly 5 failures and never ran again. WooCommerce re-registers its own recurring jobs once a day, at 3 am site time, through
action_scheduler_ensure_recurring_actions. Jobs from your own code come back only if you register them there too. - A WP-Cron callback hits a fatal error. WordPress unschedules a single event before running it, so the event that crashed was gone, with no failed status anywhere. The event due one second later didn't run in that pass either; it ran on the next minute. The health check saw nothing wrong. Only the cron script's non-zero exit code reported it.
4. Heartbeat and health check
You need both. A runner can exit cleanly every minute while one hook fails every time, and a perfect queue can't report that the cron that empties it was removed.
5. Step 1: run the queues from the system cron
Add define( 'DISABLE_WP_CRON', true ); to wp-config.php, so page loads stop starting runs. Save the script as /usr/local/bin/store-cron.sh and add it to the web server user's crontab. flock -n skips a minute if the previous run is still going, instead of stacking runs.
#!/usr/bin/env bash
# /usr/local/bin/store-cron.sh: runs WP-Cron and Action Scheduler from the system cron, then pings Oh Dear.
# wp-config.php: define( 'DISABLE_WP_CRON', true );
# crontab -u www-data -e:
# * * * * * flock -n /tmp/store-cron.lock /usr/local/bin/store-cron.sh >> /var/log/store-cron.log 2>&1
set -uo pipefail
SITE=${SITE:-/var/www/store}
PING_URL=${PING_URL:?set PING_URL to the Oh Dear ping URL of this job}
WP=${WP:-wp}
start=$(date +%s.%N)
# Both runners get a hard time limit so one hung job can't hold the lock (and the queue) forever.
timeout 50 "$WP" --path="$SITE" cron event run --due-now --quiet
cron_rc=$?
timeout 240 "$WP" --path="$SITE" action-scheduler run --batch-size=100 --batches=10 --quiet
as_rc=$?
rc=$(( cron_rc != 0 ? cron_rc : as_rc ))
runtime=$(awk -v s="$start" -v e="$(date +%s.%N)" 'BEGIN { printf "%.2f", e - s }')
echo "$(date -u +%FT%TZ) wp-cron=$cron_rc action-scheduler=$as_rc runtime=${runtime}s"
# exit_code 0 = success; anything else is reported to Oh Dear as a failed run. No ping at all = missed run.
curl -fsS -m 10 --retry 3 -o /dev/null -X POST -F "exit_code=$rc" -F "runtime=$runtime" "$PING_URL" \
|| echo "ping to Oh Dear failed" >&2
exit "$rc"- Time limits: 50 seconds for WP-Cron and 240 for Action Scheduler. A job that hangs is killed (exit code 124), reported as a failed run, and the lock is released.
- Batches: up to 10 batches of 100 actions per run. Raise
--batchesif your health check often shows a backlog. - Exit code: the first non-zero one wins. A PHP fatal error in a cron callback made WP-CLI print the error and exit with 1 in our test. Exceptions thrown inside Action Scheduler jobs don't change it: Action Scheduler catches them and marks the action failed, which is why the health check exists.
In Oh Dear, add a scheduled task to the site with the "simple" type, a frequency of 1 minute and a grace time of 5 minutes, then put its ping URL in PING_URL. Oh Dear treats a POST with a non-zero exit_code as a failed run, and a missing ping after the frequency plus grace time as a missed one. If you use the cron-expression type instead, set the server's timezone in Oh Dear.
6. Step 2: the health endpoint
Save this as wp-content/mu-plugins/store-cron-health.php, and add define( 'STORE_HEALTH_SECRET', '...' ); with a long random value to wp-config.php. It answers GET /wp-json/store-health/v1/cron only when the oh-dear-health-check-secret header matches, and returns the JSON format Oh Dear's application health monitor reads.
<?php
/**
* Plugin Name: Store cron health
* Description: Oh Dear application health endpoint for WP-Cron and Action Scheduler. GET /wp-json/store-health/v1/cron
* with the header oh-dear-health-check-secret: STORE_HEALTH_SECRET (define it in wp-config.php).
*/
defined( 'ABSPATH' ) || exit;
const STORE_HEALTH = array(
'cron_lag_warn' => 10 * MINUTE_IN_SECONDS, // oldest due WP-Cron event this late = warning
'cron_lag_fail' => 30 * MINUTE_IN_SECONDS,
'queue_lag_warn' => 10 * MINUTE_IN_SECONDS, // oldest past-due Action Scheduler action
'queue_lag_fail' => 30 * MINUTE_IN_SECONDS,
'queue_size_warn' => 500, // past-due actions waiting
'stuck_after' => 15 * MINUTE_IN_SECONDS, // in-progress longer than this: the runner died mid-batch
'failed_window' => HOUR_IN_SECONDS,
'failed_warn' => 1,
'failed_fail' => 20,
// Jobs that must always have a next run: add your own. Action Scheduler stops rescheduling a recurring action
// after 5 failures in a row, and a WP-Cron event can be cleared by a plugin, so "nothing scheduled" is a failure.
'cron_hooks' => array( 'wp_scheduled_delete', 'delete_expired_transients' ),
'as_hooks' => array( 'woocommerce_cleanup_sessions', 'woocommerce_scheduled_sales' ),
);
add_action(
'rest_api_init',
function () {
register_rest_route(
'store-health/v1',
'/cron',
array(
'methods' => 'GET',
'callback' => 'store_cron_health',
'permission_callback' => function ( WP_REST_Request $request ) {
return defined( 'STORE_HEALTH_SECRET' ) && '' !== STORE_HEALTH_SECRET
&& hash_equals( STORE_HEALTH_SECRET, (string) $request->get_header( 'oh-dear-health-check-secret' ) );
},
)
);
}
);
function store_cron_health() {
$cfg = apply_filters( 'store_cron_health_config', STORE_HEALTH );
$results = array( store_health_wp_cron( $cfg ) );
if ( class_exists( 'ActionScheduler' ) ) {
array_push( $results, ...store_health_action_scheduler( $cfg ) );
}
$response = new WP_REST_Response( array( 'finishedAt' => time(), 'checkResults' => $results ) );
$response->header( 'Cache-Control', 'no-store' ); // a cached "ok" is worse than no answer
return $response;
}
function store_health_result( $name, $label, $status, $message, $summary, $meta ) {
return array(
'name' => $name,
'label' => $label,
'status' => $status,
'notificationMessage' => 'ok' === $status ? '' : $message,
'shortSummary' => $summary,
'meta' => $meta,
);
}
function store_health_level( $value, $warn, $fail ) {
return $value >= $fail ? 'failed' : ( $value >= $warn ? 'warning' : 'ok' );
}
function store_health_age( $seconds ) {
return $seconds < 120 ? $seconds . 's' : ( $seconds < 7200 ? round( $seconds / 60 ) . 'm' : round( $seconds / 3600, 1 ) . 'h' );
}
function store_health_wp_cron( $cfg ) {
$due = wp_get_ready_cron_jobs();
$oldest = $due ? min( array_keys( $due ) ) : time();
$lag = max( 0, time() - $oldest );
$missing = array_values( array_filter( $cfg['cron_hooks'], fn( $hook ) => ! wp_next_scheduled( $hook ) ) );
$status = $missing ? 'failed' : store_health_level( $lag, $cfg['cron_lag_warn'], $cfg['cron_lag_fail'] );
$hooks = $due ? array_keys( reset( $due ) ) : array();
$message = $missing
? 'WP-Cron has no next run for: ' . implode( ', ', $missing )
: sprintf( 'WP-Cron is %s behind (oldest due: %s). Is the system cron running?', store_health_age( $lag ), implode( ', ', $hooks ) );
return store_health_result(
'WpCron',
'WP-Cron',
$status,
$message,
$missing ? count( $missing ) . ' missing' : store_health_age( $lag ) . ' behind',
array(
'due_events' => array_sum( array_map( 'count', $due ) ),
'lag_seconds' => $lag,
'missing_hooks' => implode( ',', $missing ),
'disable_wp_cron' => defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON,
)
);
}
function store_health_action_scheduler( $cfg ) {
global $wpdb;
$t = $wpdb->prefix . 'actionscheduler_actions';
$now = time();
$gmt = fn( $ts ) => gmdate( 'Y-m-d H:i:s', $ts );
// Each query uses one of Action Scheduler's own indexes: (status, scheduled_date_gmt) and (status, last_attempt_gmt).
$queue = $wpdb->get_row( $wpdb->prepare( "SELECT COUNT(*) AS n, MIN(scheduled_date_gmt) AS oldest FROM $t WHERE status = 'pending' AND scheduled_date_gmt <= %s", $gmt( $now - MINUTE_IN_SECONDS ) ) );
$stuck = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM $t WHERE status = 'in-progress' AND last_attempt_gmt < %s", $gmt( $now - $cfg['stuck_after'] ) ) );
$fails = $wpdb->get_results( $wpdb->prepare( "SELECT hook, COUNT(*) AS n FROM $t WHERE status = 'failed' AND last_attempt_gmt >= %s GROUP BY hook ORDER BY n DESC", $gmt( $now - $cfg['failed_window'] ) ) );
$lag = $queue->n ? $now - strtotime( $queue->oldest . ' UTC' ) : 0;
$status = store_health_level( $lag, $cfg['queue_lag_warn'], $cfg['queue_lag_fail'] );
if ( 'ok' === $status && $queue->n >= $cfg['queue_size_warn'] ) {
$status = 'warning';
}
$failed = array_sum( array_map( fn( $r ) => (int) $r->n, $fails ) );
$top = implode( ', ', array_map( fn( $r ) => "$r->hook ($r->n)", array_slice( $fails, 0, 3 ) ) );
$missing = array_values( array_filter( $cfg['as_hooks'], fn( $hook ) => ! as_has_scheduled_action( $hook ) ) );
return array(
store_health_result(
'ActionSchedulerQueue',
'Action Scheduler queue',
$status,
sprintf( '%d actions past due, oldest %s late. The queue runner is not keeping up or not running.', $queue->n, store_health_age( $lag ) ),
$queue->n . ' past due',
array( 'past_due' => (int) $queue->n, 'oldest_lag_seconds' => $lag )
),
store_health_result(
'ActionSchedulerStuck',
'Action Scheduler stuck actions',
$stuck ? 'failed' : 'ok',
sprintf( '%d actions in progress for more than %s: a runner died mid-batch and nothing has cleaned up since.', $stuck, store_health_age( $cfg['stuck_after'] ) ),
$stuck . ' stuck',
array( 'stuck' => $stuck )
),
store_health_result(
'ActionSchedulerFailed',
'Action Scheduler failures',
store_health_level( $failed, $cfg['failed_warn'], $cfg['failed_fail'] ),
sprintf( '%d actions failed in the last %s: %s', $failed, store_health_age( $cfg['failed_window'] ), $top ),
$failed . ' failed',
array( 'failed' => $failed, 'top_hooks' => $top )
),
store_health_result(
'ActionSchedulerRecurring',
'Recurring jobs scheduled',
$missing ? 'failed' : 'ok',
'No pending run for: ' . implode( ', ', $missing ) . '. A recurring action that fails 5 times in a row is not rescheduled.',
$missing ? count( $missing ) . ' missing' : 'all scheduled',
array( 'missing' => implode( ',', $missing ) )
),
);
}- Queries: all three use Action Scheduler's own indexes,
(status, scheduled_date_gmt)and(status, last_attempt_gmt). With 1.06 million rows in the table, they took 2.4 ms together. The whole request took about 0.7 seconds on our Windows test server, almost all of it WordPress loading. Cache-Control: no-store: some hosts cache REST responses. A cached "ok" would hide an outage.- Your own jobs: add the hooks that must always have a next run to
cron_hooksoras_hooks, through thestore_cron_health_configfilter. - The default list:
woocommerce_cleanup_sessionsandwoocommerce_scheduled_salesare WooCommerce recurring actions. If one is missing for more than a day, WooCommerce's daily re-registration isn't running either.
In Oh Dear, enable application health monitoring for the site, enter the endpoint URL and the same secret, and keep the default one-minute interval. Oh Dear sends a separate notification if finishedAt is more than 10 minutes old.
7. What each check means
| Check | Warning | Failed | Usually means | First thing to try |
|---|---|---|---|---|
| WP-Cron | Oldest due event 10 min late | 30 min late, or a listed hook has no next run | The system cron isn't running, or a plugin cleared an event | wp cron event list, then check the crontab and the cron log |
| Action Scheduler queue | 500+ actions past due, or the oldest 10 min late | Oldest 30 min late | The runner isn't running or can't keep up | wp action-scheduler run --batch-size=100 by hand and watch how long jobs take |
| Stuck actions | Any action in progress for 15+ min | A runner was killed mid-batch and none has run since | Same as above; the next run marks it failed | |
| Failures | 1 in the last hour | 20 in the last hour | One hook failing over and over: an expired API key, a changed endpoint | The message names the top hooks; see Tools, Scheduled Actions, Failed |
| Recurring jobs | A listed hook has nothing pending | It failed 5 times in a row and was dropped | Fix the cause, then register it again |
To make your own recurring jobs come back like WooCommerce's do, register them on Action Scheduler's daily hook with unique set:
add_action( 'action_scheduler_ensure_recurring_actions', function () {
as_schedule_recurring_action( time() + HOUR_IN_SECONDS, HOUR_IN_SECONDS, 'store_sync_stock', array(), 'store', true );
} );8. Test results
| Scenario | Result |
|---|---|
| No secret header, wrong secret | Refused; right secret: 200 with Cache-Control: no-store |
| Response format | Valid against Oh Dear's documented fields, statuses and limits |
| WP-Cron 15 / 40 min behind | Warning / failed naming the hook; ok again after one cron run |
| A core event cleared by a plugin | Still missing after page loads; failed |
| WooCommerce session cleanup unscheduled | Failed; back to ok after WooCommerce's daily re-registration hook |
| 600 actions 2 min past due | Warning (queue size); one 45 min late: failed; drained by one cron run |
| Action in progress for 20 min | Failed until a runner ran; then Action Scheduler marked it failed |
| 3 / 23 failed actions in an hour | Warning naming oh_test_throw (3) / failed; the cron script still exited 0 |
| Recurring action that always throws | Dropped after the 5th failure; health check: failed |
| Fatal error in a WP-Cron callback | Exit code 1 sent in the ping; the event was gone; health check stayed ok |
| 1.06 million rows, 5,000 past due | Correct count, 2.4 ms of queries |
9. Oh Dear vs custom scripts
You can write the alerting yourself: a script that runs the health query and emails you. The catch is that it runs on the same server, from the same cron, as the thing it watches. When the crontab is gone or the server is down, the checker is gone too, and silence looks like health. The heartbeat has to come from outside.
| Custom script with email | Oh Dear | |
|---|---|---|
| Cron stopped | Not detected: the script stopped too | Missed-ping alert after frequency plus grace |
| Run crashed | Only if the script survives the crash to report it | Failed-run alert from the exit code |
| Queue health | Your query, your thresholds | Same endpoint, polled every minute, with history |
| Alerting | You write the delivery, repeats and history | Built-in notification channels and check history |
| Also covers | Whatever you add | Uptime, certificate and broken-link checks on the same site |
| Cost | Your time | From €15 a month |
Self-hosted options exist: Healthchecks.io is open source and takes the same kind of ping, and Uptime Kuma has push monitors. Run either on a different server from your store, or they share its failures.
10. What it costs
Oh Dear's plans include application health and cron monitoring on every tier. Solo is €15 a month for 2 sites, Solo Plus €29 for 5, Freelance €49 for 10 and Studio €99 for 25, excluding VAT. Yearly billing gives two months free, and there's a 10-day trial without a card (pricing page, October 2026). Each site comes with 5 cron monitors; the setup here uses one. The two monitors in this guide cost nothing extra on top of the site.
Related: centralizing PHP and WooCommerce error logs to see why a job failed, zero-downtime deployments, and SSL, checkout and API monitoring.
11. Tools
Top picks
| Tool | Role | Why it fits |
|---|---|---|
| Heartbeat and health monitoring | Scheduled-task monitors with exit codes and grace times, and an application health monitor that reads your own checks every minute. Both are on every plan, next to uptime and certificate monitoring. From €15 a month for 2 sites (October 2026). |
Alternatives: free tools and references
We don't earn anything from these links.
- Healthchecks.io: open-source cron monitoring with ping URLs; hosted or self-hosted.
- Uptime Kuma: self-hosted monitoring with push monitors.
- Action Scheduler WP-CLI: the
wp action-scheduler runoptions used here. - Hooking WP-Cron into the system task scheduler: WordPress's own guide to
DISABLE_WP_CRON.
12. Pitfalls
- Setting
DISABLE_WP_CRONwithout a system cron: nothing runs at all. Add the crontab line in the same change. - Running the cron as root: files and caches it creates belong to root, and the web server can't change them. Use the web server's user.
- Trusting the "Past-due" notice in wp-admin: it waits until an action is a day late.
- Exit code 0 means all is well: failed Action Scheduler jobs don't change it. Watch the failure count.
- Recurring jobs from your own code: after 5 failures in a row they're gone for good, unless you register them on
action_scheduler_ensure_recurring_actions. - A health endpoint behind a page cache or basic auth: Oh Dear has to reach PHP, with its header intact.
- Monitoring from the same server: it goes down with the store.
Run WP-Cron and Action Scheduler from the system cron with time limits, and send every run's exit code to an outside heartbeat. Add a health endpoint for queue lag, stuck and failed jobs, and recurring jobs that disappeared. Oh Dear watches both from outside your server, which is the one thing a script on the store can't do for itself.