A renewal email lands four hours after the invoice goes out. A nightly import stops for six days and nobody notices until the numbers are wrong. Nothing in the logs says cron failed, because nothing failed in a way that logs.
WP-Cron is a due-check, not a scheduler. On each page request WordPress looks at the cron option, finds events whose timestamp has passed, and fires a non-blocking loopback request to wp-cron.php to run them. No traffic, no runs. That is the whole failure mode, and it covers most "the plugin stopped working" reports.
Confirm the event is late
wp cron event list --fields=hook,next_run_gmt,recurrence,args next_run_gmt is UTC. Compare it with date -u, not with the clock in your site's timezone settings. An event with a next_run_gmt hours in the past is overdue. An event that is missing from the list was never scheduled, which is a different bug: plugin inactive, scheduling hooked to admin-only code, or something called wp_clear_scheduled_hook.
Look at the spawn lock
Before spawning, WordPress writes a doing_cron transient and holds it for WP_CRON_LOCK_TIMEOUT seconds, 60 by default. That lock is why a dead run doesn't cause every page load to start another one, and it is also why a dead run silences the next minute of attempts.
wp transient get doing_cron A value that persists across several minutes means something is still holding it. Clearing it is safe once you know no run is in flight:
wp transient delete doing_cron Check that spawning is enabled
wp config get DISABLE_WP_CRON wp config get ALTERNATE_WP_CRON DISABLE_WP_CRON set to true means page loads never trigger anything. That is correct when a system cron exists, and a silent outage when someone copied the setting from a tutorial and the crontab line never got added.
Test the loopback request
The spawn is an HTTP request from the server to itself. Basic auth, a maintenance plugin, a WAF rule on wp-cron.php, or a hosts-file mismatch kills it while the site still looks fine to visitors.
wp eval 'var_dump( wp_remote_retrieve_response_code( wp_remote_get( add_query_arg( "nocache", time(), site_url( "wp-cron.php" ) ), array( "timeout" => 5 ) ) ) );' You want 200. This call does run any due events, which is harmless and a little useful. Tools → Site Health → Info has a loopback test that tells you the same thing without typing.
Find the other scheduler
crontab -l sudo -u www-data crontab -l Hosting panels add entries under their own user. Two crons calling wp cron event run --due-now is survivable, since an event only fires once per due timestamp, but two crons with different paths or one running as root against a different wp-config.php produces behaviour that looks random.
Switch
In wp-config.php, above the "stop editing" line:
define( 'DISABLE_WP_CRON', true ); In the crontab of the user that owns the files, not root:
*/5 * * * * flock -n /var/lock/example-wp-cron.lock -c 'cd /var/www/example.com/current && /usr/local/bin/wp cron event run --due-now --quiet' What each part is doing:
-
cdinto the install sowpfindswp-config.phpwithout a--pathflag. -
flock -nreturns immediately when the previous run is still going. Without it a slow run overlaps the next one and two processes write the same options. - Run as the site user.
--allow-rootworks and leaves root-owned files inwp-content, which breaks uploads and plugin updates later. - Five minutes is a reasonable default. Each invocation boots WordPress, so a small site has a floor of roughly one run per minute.
- A time-critical hook can get its own tighter line (
wp cron event run my_hook) without changing the general interval.
Without shell access, a panel cron or an external cron service hitting a URL works:
https://example.com/wp-cron.php?doing_wp_cron=1699999999 Include a changing value in the query string or a CDN may serve a cached response indefinitely. Test it once and confirm a known event moved in wp cron event list; whether a direct hit to wp-cron.php still executes events while DISABLE_WP_CRON is on is not something to assume across versions and hosts.
Shared hosting without SSH, a panel cron nobody documented, a site that changed hands with no notes: in any of those the switch above takes longer than ten minutes, because it turns into a conversation with the host. Most support desks will add a crontab line if you hand them the exact command and the path. Moving to a host that gives you shell access is the other clean route, and it is a reasonable moment to have the site rebuilt properly: AumCreate does a fixed-scope WordPress deployment onto hosting you already own — template installed, every page rewritten with your copy, a preview link, one round of changes, then live, with a one-page note on how to edit content — for a one-time $399 (what the handover covers). Domain and hosting stay in your name and are billed by those providers, and there is no annual fee from us. It is a build, so if the cron tasks are the actual problem, ask for the scheduled events and who owns them to be written into the handover.
What breaks quietly afterwards
Queues that are not WP-Cron. WooCommerce and a long list of plugins put background work into Action Scheduler, which keeps its own tables and its own runner. wp cron event run --due-now does not drain that queue. Check the scheduled-actions screen after the switch, and add a deterministic trigger if you want one:
*/5 * * * * cd /var/www/example.com/current && /usr/local/bin/wp action-scheduler run --quiet Long events blocking the batch. wp cron event run --due-now runs due events in a single process. One event stuck on a remote API eats the run and everything behind it waits for the next tick. Batch heavy work — 50 rows at a time, store a cursor, let the next invocation pick up the rest.
Make a missed run visible
Record the last successful run, and take a lock so a slow run cannot double-process:
add_action( 'myplugin_sync', function () { global $wpdb; $got = $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, %d)', 'myplugin_sync', 5 ) ); if ( '1' !== (string) $got ) { return; // another run holds the lock } try { // work here update_option( 'myplugin_last_sync', time(), false ); } finally { $wpdb->query( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', 'myplugin_sync' ) ); } } ); GET_LOCK is per database connection, so it releases when the process dies, including a hard kill. That makes it a better fit than an option flag that survives a crash and blocks the next ten runs.
Expose the age of that timestamp to whatever already watches the site:
add_action( 'rest_api_init', function () { register_rest_route( 'health/v1', '/cron', array( 'methods' => 'GET', 'permission_callback' => '__return_true', 'callback' => function () { $last = (int) get_option( 'myplugin_last_sync', 0 ); $age = $last ? time() - $last : PHP_INT_MAX; return new WP_REST_Response( array( 'last_sync_age_seconds' => $age ), $age < 3600 ? 200 : 500 ); }, ) ); } ); A monitor expecting 200 from that endpoint every five minutes catches the failure wp cron event list never shows: a cron entry that exists, runs on schedule, and does nothing useful. Pair it with an attempt counter and a cap, so a task that fails permanently lands somewhere readable instead of retrying forever with no record.
The switch is ten minutes of work. The part that needs thinking is which events deserve a system cron at all: anything that moves money, sends mail, or expires access belongs there; a weekly transient cleanup does not.




