Guide
WordPress cron hook stuck for years — finding silently dead scheduled hooks
1. Problem
You went looking for one slow scheduled job and found something worse. A row in wp_options under cron has a next_run timestamp from 2021. The site is in 2026. The hook is 156,824,180 seconds overdue — roughly five years. It is not a typo, it is not clock skew, and wp-cron.php has been firing the entire time. Other hooks run on schedule. This one does not.
If you searched "wordpress cron hooks stuck for days" or "wordpress wp_cron hook never running", this is the case nobody writes about. It is not "scheduled posts not publishing" — that is an acute, recent symptom and you would have noticed yesterday. It is not "wp-cron disabled, set up real cron" — your cron is firing fine; you can see other hooks executing on time. This is the third failure mode: a single hook that errored once, got skipped, never got rescheduled, and has been sitting in the cron array as a tombstone ever since. Often the plugin that registered the hook has been deleted. The callback no longer exists. WordPress does not care; it keeps the entry around forever.
You will not find this in the admin UI. Tools → Site Health will not flag it. WP Crontrol will list it but only if you scroll. The only way you find these is by asking a very specific question: which of my scheduled hooks have a next_run timestamp older than now by more than a few minutes, and how far overdue are they? That question has a name in Logystera: wp_cron_overdue_hook_delay_sec.
2. Impact
A hook overdue by five years is a smoke signal. The hook itself is dead — but the conditions that produced it are not.
Concrete impact:
- Silent feature regression. The hook used to do something — rebuild a sitemap, sync inventory, expire transients, dispatch a digest email. Whatever it was, it has not run since the day it broke. Customers may have been quietly unaffected, or quietly very affected, for years.
- Plugin uninstalls that did not clean up. Most tombstones are from plugins deactivated and deleted without ever calling
wp_clear_scheduled_hook(). The callback is gone, the entry remains and points to nothing. - Blocked cron queue. WordPress sorts the cron array by timestamp. Long-overdue entries sit at the top of the queue and get re-evaluated on every
wp-cron.phprequest, then skipped because no callback is registered. - Indicator of broader cron health failure. If one hook is silently dead for five years, others may be silently late by hours — late enough to break SLAs but not obvious.
- Forensic value. A hook with a
next_runpredating a known migration tells you exactly which subsystem broke and was never repaired.
The five-year row is the alarm. The question is what else is in that list.
3. Why It’s Hard to Spot
This failure has nearly perfect camouflage:
- No error. Nothing is throwing. The hook is not "failing" — it is simply not running. PHP error logs are clean.
- No "missed schedule" event. WordPress emits
missed_schedulewhen a hook runs late, which requires it to run at all. A hook that never runs does not generate this event. Looking only atwp.cron type=missed_schedulewill miss every tombstone. - Site Health says you're fine. Core's Site Health checks whether
wp-cron.phpitself can be reached. It does not inspect individual hook timestamps. - Uptime monitors are blind. External pingers check that pages load. They cannot see into the cron array.
- WP Crontrol shows it but does not flag it. It lists "next run" as a relative time ("In 5 years ago"), which reads as a UI quirk.
- It survives migrations. Copy the database from prod to staging, the tombstone comes with it.
The only way to find these is to actively ask, for every hook, how far behind is its next scheduled run?
4. Cause
WordPress cron is not a daemon. It is an array, serialized into a single row in wp_options (option_name = 'cron'). The structure looks roughly like this:
[
1714003200 => [
'wp_scheduled_delete' => [
'hash_of_args' => [
'schedule' => 'daily',
'args' => [],
'interval' => 86400,
],
],
],
1714004100 => [
'old_plugin_cleanup_hook' => [
'hash_of_args' => [
'schedule' => false, // one-shot, never reschedules
'args' => [],
],
],
],
]
When a request comes in (or when real system cron pings wp-cron.php?doing_wp_cron), WordPress iterates this array, finds entries whose timestamp is in the past, calls do_action() for each hook, and — if the hook has a recurrence — schedules the next occurrence by adding interval to the current time.
A hook becomes a permanent tombstone in two ways:
- The callback is gone. The plugin that called
add_action('old_plugin_cleanup_hook', 'cleanup_fn')has been deleted.do_action('old_plugin_cleanup_hook')runs, fires no listeners, and the entry is treated as "done." But becauseschedule => false(or because the recurrence logic lives in the callback itself), nothing reschedules it and nothing removes it. WordPress leaves the original entry alone. - The callback throws fatal and the next-run logic is in the callback. Some plugins wrap their own rescheduling: "do work, then call
wp_schedule_single_event()for the next iteration." If the work fatals, the reschedule line never executes, the original entry remains, and oncenext_runis in the past it stays in the past forever.
The Logystera signal wp_cron_overdue_hook_delay_sec is a per-hook gauge: for every hook in the cron array, it emits the delta between now and next_run if that delta is positive. A healthy hook reports 0 or a small positive number (it is between two scheduled runs). A stuck hook reports the full age of the tombstone — minutes, hours, days, or 156 million seconds.
Three supporting signals frame the picture:
wp_cron_overdue_count— total number of hooks with delay > threshold (default 60s). One stuck hook keeps this above zero forever.wp_cron_overdue_ratio— overdue / total. A small site with one tombstone has a ratio that looks bad relative to its size.wp_cron_health_checks_total— counter of how often the WP plugin's cron health probe ran. Confirms the data is fresh; rules out "we just stopped looking."wp.cron type=missed_schedule— emitted when a hook fires later than expected. A tombstone hook never fires, so it does not producemissed_schedule. That is part of the trap.
5. Solution
5.1 Diagnose (logs first)
Start by getting the actual list of overdue hooks. There are three ways, in increasing order of usefulness.
1. WP-CLI: dump the full cron array and sort by next_run.
wp cron event list --format=csv --fields=hook,next_run,recurrence,next_run_relative \
| awk -F, 'NR>1 && $2 ~ /^[0-9]+$/ { print $0 }' \
| sort -t, -k2 -n \
| head -20
The output's top rows are your tombstones. A next_run_relative of 5 years ago or 8 months ago is the smoking gun. This is what wp_cron_overdue_hook_delay_sec measures, computed for you.
2. Direct database read (if WP-CLI is unavailable):
wp db query "SELECT option_value FROM wp_options WHERE option_name='cron'" --skip-column-names \
| php -r '$c = unserialize(file_get_contents("php://stdin")); foreach ($c as $ts => $hooks) { if (!is_int($ts)) continue; if ($ts < time() - 60) { foreach (array_keys($hooks) as $h) { printf("%-12d %s (overdue %ds)\n", $ts, $h, time()-$ts); } } }'
This prints every hook with a timestamp more than 60 seconds in the past, which is exactly the population that increments wp_cron_overdue_count.
3. PHP error log — for the hook that died loudly:
If the tombstone was created by a fatal in the callback, the original error is probably in the PHP log from years ago. You usually do not have logs going back that far, but if you do:
grep -E "PHP Fatal error.*old_plugin_cleanup_hook" /var/log/php-fpm/error.log* 2>/dev/null
grep -i "wp-cron" /var/log/nginx/access.log | grep -v "200 " | head
The grep for wp-cron non-200 responses surfaces the broader pattern: wp.cron type=missed_schedule events come from cron requests that timed out or 500'd, which is the concurrent symptom when callbacks fatal.
Tying each query back to its signal:
wp cron event listsorted bynext_run→ produceswp_cron_overdue_hook_delay_secper hook.- Counting rows where
now - next_run > 60s→ produceswp_cron_overdue_count. - Dividing that count by total hooks →
wp_cron_overdue_ratio. - The fact that you can run any of this at all → emits
wp_cron_health_checks_totalfrom the WP plugin's probe. - A separate stream of cron events that ran late →
wp.cron type=missed_schedule. A tombstone will not show up here; that is precisely why the per-hook delay gauge exists.
If you have a hook with delay > 86400 (one day) and zero missed_schedule events for it, it is dead, not slow.
5.2 Root Causes
(see root causes inline in 5.3 Fix)
5.3 Fix
Fixes vary by root cause. Identify the cause first, then apply the matching repair.
Cause A: Plugin uninstalled, hook orphaned. Most common. The hook name will reference a plugin that is no longer in wp-content/plugins/.
wp cron event list --fields=hook --format=ids | grep -i old_plugin
wp cron event delete old_plugin_cleanup_hook
Signal: this cause does not produce any new signal. It produces absence — the hook stops appearing in wp_cron_overdue_hook_delay_sec because the entry is gone.
Cause B: Callback fatals every run, breaking its own re-scheduling. The plugin still exists, but its callback throws. The hook was scheduled once, fataled, and the re-scheduling code never ran. This produces php.fatal entries that correlate with the original hook timestamp.
grep -E "Fatal error.*\.php" /var/log/php-fpm/error.log | grep -i <plugin_slug>
Fix: patch or update the plugin, then either let it reschedule itself on next page load (if it has activation hooks) or reschedule manually:
wp cron event schedule sync_inventory now hourly
Signal mapping: php.fatal → broken callback → tombstone in wp_cron_overdue_hook_delay_sec. Once the callback is fixed, the hook starts running and the per-hook delay drops to ~0.
Cause C: One-shot event (wp_schedule_single_event) that ran past its TTL. WordPress will not run a single event whose timestamp is older than 10 minutes by default unless WP_CRON_LOCK_TIMEOUT overrides it. Old single events accumulate. They are usually harmless but inflate wp_cron_overdue_count.
wp cron event delete <hook_name>
Signal mapping: stale single events appear only in wp_cron_overdue_hook_delay_sec; they never produce missed_schedule. Deleting them brings wp_cron_overdue_count back to its true working value.
Cause D: Cron array corruption (rare). Serialization mismatch from a botched migration or character-set conversion can leave the array unparseable. Symptom: wp cron event list returns empty or PHP notices about unserialize(). Recovery is to dump, fix, and rewrite the option, or in the worst case wp option delete cron (WordPress recreates it on next request, losing scheduled state).
Signal mapping: corruption tends to spike wp_cron_health_checks_total failure rate and produces PHP Notice: unserialize() entries in error logs.
Cause E: DISABLE_WP_CRON set without real cron configured. This is the cron-not-firing-at-all case. It is not what produces year-old tombstones — it produces uniformly overdue hooks. If wp_cron_overdue_ratio is near 1.0 (everything is overdue), this is the cause, not a single bad hook.
grep DISABLE_WP_CRON wp-config.php
crontab -l | grep wp-cron
Fix: configure system cron (/5 * curl -s https://example.com/wp-cron.php?doing_wp_cron > /dev/null) or unset DISABLE_WP_CRON.
5.4 Verify
After cleaning up tombstones and patching broken callbacks, the verification is mechanical and signal-driven.
The signal that should disappear: wp_cron_overdue_hook_delay_sec for the specific hook should drop to 0 or stop being emitted entirely (if you deleted the entry). wp_cron_overdue_count should fall to its normal baseline — usually 0 or 1 for a healthy site that may have one hook briefly overdue between runs.
Re-run the diagnostic:
wp cron event list --format=csv --fields=hook,next_run,next_run_relative \
| awk -F, 'NR>1' \
| sort -t, -k2 -n \
| head -10
Healthy output: every next_run_relative is now, in X minutes, or in X hours — never X days/months/years ago. The minimum next_run should be within interval seconds of the present.
What to grep for over the next 24 hours:
grep -i "missed_schedule\|wp-cron" /var/log/php-fpm/error.log | tail -50
Healthy: zero new missed_schedule events for the hook you fixed. If you fixed Cause B (broken callback), you should also see zero new php.fatal entries from that callback. The hook should now be producing its expected work artifacts — sitemaps regenerated, emails dispatched, transients cleaned — within one full interval of the schedule.
Timeframe: give it 2x the longest interval (so a daily hook needs 48 hours of clean signal) before declaring it resolved. Tombstones can re-emerge if the underlying cause was a plugin update that rolls back.
6. How to Catch This Early
Fixing it is straightforward once you know the cause. The hard part is knowing it happened at all.
This issue surfaces as wp_cron_overdue_hook_delay_sec.
This failure mode exists because nobody is asking the question. The cron array is invisible to the admin UI, to uptime checks, to PHP error logs (nothing is erroring), and to standard application metrics. The hook is dead, but its corpse looks identical to a hook that has not yet run.
The only way to detect it is to enumerate every scheduled hook, compute the per-hook delay, and alert when any single delay exceeds what its interval allows. That is wp_cron_overdue_hook_delay_sec, evaluated against a rule that knows the hook's recurrence. Logystera's WP plugin emits this gauge from inside the site every health check cycle (wp_cron_health_checks_total), so a five-year tombstone surfaces the first time the rule runs — not five years later when somebody happens to read the cron array by hand.
Supporting signals close the loop: wp_cron_overdue_count catches multi-hook degradation, wp_cron_overdue_ratio catches the "DISABLE_WP_CRON without real cron" case, and wp.cron type=missed_schedule catches hooks that run but slip — a different failure with different remediation. None of this triggers a default WordPress alert. The data has always been in the database; nobody was looking at it.
7. Related Silent Failures
- WordPress scheduled posts not publishing — diagnosing missed cron jobs. Acute, recent symptom in the same cluster.
wp.cron type=missed_schedulefires; tombstones do not. - WordPress wp-cron not running — verifying real system cron. Different failure mode where everything is overdue at once. Diagnosed via
wp_cron_overdue_rationear 1.0. - WordPress email delivery failures from cron-driven digests. Often a downstream symptom: the cron hook fires but the email callback fatals. Look for
php.fatalcorrelated withwp.cronevents. - Plugin deactivation that leaves orphaned data. Same root cause family as Cause A above — uninstalls that don't clean up. Surfaces as orphaned cron hooks, orphaned options, and orphaned scheduled tasks.
- WordPress transient cache never expiring. Often dependent on a cron hook (
wp_scheduled_delete) that has itself become a tombstone — transients accumulate forever and bloatwp_options.
See what's actually happening in your WordPress system
Connect your site. Logystera starts monitoring within minutes.