DevOps & monitoring

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.

How this guide was made

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

QuestionAnswer
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 storeOne mu-plugin with three indexed queries (2.4 ms on a 1.06 million-row actions table in our test) and one shell script
CostOh 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.php with a 0.01-second timeout. A doing_cron lock 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.

  1. The trigger stops. With DISABLE_WP_CRON set 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.
  2. The runner can't keep up. We queued 3,000 jobs of 50 ms each. The runner started by one wp-cron.php request 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.
  3. 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.
  4. 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.
  5. 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

A stuck cron vs an Oh Dear heartbeat alert
A stuck cron vs an Oh Dear heartbeat alertSystem cron, every minute → Run finished / Run crashed / Cron stopped → All green / Alert: failed run / Alert: missed pingSystem cron, every minute: flock starts store-cron.sh: due WP-Cron events, then Action Scheduler, each with a time limitSystem cron, every minuteflock starts store-cron.sh: due WP-Cron events, then ActionScheduler, each with a time limitRun finished: POST exit_code=0 to the ping URLRun finishedPOST exit_code=0to the ping URLRun crashed: POST exit_code=1: WP-CLI caught a PHP fatal errorRun crashedPOST exit_code=1:WP-CLI caught aPHP fatal errorCron stopped: no ping at allCron stoppedno ping at allAll green: the next ping is due within the frequency plus grace timeAll greenthe next ping isdue within thefrequency plusgrace timeAlert: failed run: the error is in the cron logAlert: failedrunthe error is inthe cron logAlert: missed ping: sent once frequency plus grace time passesAlert: missedpingsent oncefrequency plusgrace time passes
The heartbeat answers "did the runner run, and did it exit cleanly?" The health endpoint, polled by Oh Dear every minute, answers "is the work getting done?": queue lag, stuck and failed actions, and recurring jobs with no next run.

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.

Bash
#!/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 --batches if 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
<?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_hooks or as_hooks, through the store_cron_health_config filter.
  • The default list: woocommerce_cleanup_sessions and woocommerce_scheduled_sales are 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

CheckWarningFailedUsually meansFirst thing to try
WP-CronOldest due event 10 min late30 min late, or a listed hook has no next runThe system cron isn't running, or a plugin cleared an eventwp cron event list, then check the crontab and the cron log
Action Scheduler queue500+ actions past due, or the oldest 10 min lateOldest 30 min lateThe runner isn't running or can't keep upwp action-scheduler run --batch-size=100 by hand and watch how long jobs take
Stuck actionsAny action in progress for 15+ minA runner was killed mid-batch and none has run sinceSame as above; the next run marks it failed
Failures1 in the last hour20 in the last hourOne hook failing over and over: an expired API key, a changed endpointThe message names the top hooks; see Tools, Scheduled Actions, Failed
Recurring jobsA listed hook has nothing pendingIt failed 5 times in a row and was droppedFix 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:

Code
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

ScenarioResult
No secret header, wrong secretRefused; right secret: 200 with Cache-Control: no-store
Response formatValid against Oh Dear's documented fields, statuses and limits
WP-Cron 15 / 40 min behindWarning / failed naming the hook; ok again after one cron run
A core event cleared by a pluginStill missing after page loads; failed
WooCommerce session cleanup unscheduledFailed; back to ok after WooCommerce's daily re-registration hook
600 actions 2 min past dueWarning (queue size); one 45 min late: failed; drained by one cron run
Action in progress for 20 minFailed until a runner ran; then Action Scheduler marked it failed
3 / 23 failed actions in an hourWarning naming oh_test_throw (3) / failed; the cron script still exited 0
Recurring action that always throwsDropped after the 5th failure; health check: failed
Fatal error in a WP-Cron callbackExit code 1 sent in the ping; the event was gone; health check stayed ok
1.06 million rows, 5,000 past dueCorrect count, 2.4 ms of queries
Jobs finished from a 3,000-job backlog (50 ms per job)
Jobs finished from a 3,000-job backlog (50 ms per job)Default runner, one wp-cron.php request: 482 jobs; WP-CLI runner, one minute: 985 jobsDefault runner, one wp-cron.php request: 482 jobsDefault runner, one wp-cron.php request482 jobsWP-CLI runner, one minute: 985 jobsWP-CLI runner, one minute985 jobs
Measured on our test store. The default runner stops at its 30-second limit, and it only starts when a page load triggers WP-Cron. Real jobs that call payment or shipping APIs are often slower than 50 ms.

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 emailOh Dear
Cron stoppedNot detected: the script stopped tooMissed-ping alert after frequency plus grace
Run crashedOnly if the script survives the crash to report itFailed-run alert from the exit code
Queue healthYour query, your thresholdsSame endpoint, polled every minute, with history
AlertingYou write the delivery, repeats and historyBuilt-in notification channels and check history
Also coversWhatever you addUptime, certificate and broken-link checks on the same site
CostYour timeFrom €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

ToolRoleWhy it fits
Oh DearHeartbeat and health monitoringScheduled-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.

12. Pitfalls

  • Setting DISABLE_WP_CRON without 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.
Bottom line

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.

David Lewin
David Lewin

Founder of StealthScale.io and e-commerce infrastructure engineer writing field-tested playbooks on proxies, cloud VPS, anti-detect browsers and SEO.