Nothing on this server runs WP-Cron. A webshell scanner did, three times. It stopped on 13 September 2026 and so did the site: 55 of 57 scheduled events overdue

Who Runs Your WP-Cron? Ours Was a Vulnerability Scanner

WP-Cron is the scheduler inside WordPress: the queue of things a site does on its own, such as checking for updates, deleting expired transients, sending pingbacks, and running whatever its plugins registered. It has no clock. It runs when something requests wp-cron.php, and on a stock install the something is the next visitor. Set DISABLE_WP_CRON to true in wp-config.php, as this site did (the file’s timestamp says March), and nothing requests it unless you add a real cron job to do so. This site never added one. The fourth Site Health flag I measured this week, “A scheduled event has failed”, is the result: 55 of the 57 scheduled events are overdue, the oldest since 13 September at 05:26 UTC, 98.5 hours before I read the queue this morning.

I set out to do two things: date the stall properly, and list what stopped. Both are below. The third question, what had been running the scheduler until the 13th, was supposed to be a footnote, because nothing on this account does. The access log answered it anyway. In the seventeen days the port-80 log covers, WP-Cron ran exactly three times, and every one of those times it ran because a vulnerability scanner’s wordlist has wp-cron.php in it, between m.php and wawan.php.

This is the seventh post this month built from the site’s own log or database (yesterday’s measured the 1.6 s bootstrap; the admin-ajax count is where the Action Scheduler numbers below come from). Same method: read the instrument, then the code that wrote it, then the log.

What Site Health’s test does, and the one thing it gets wrong here

The scheduled-events test lives in WP_Site_Health. Its constructor sets two thresholds. On a site where DISABLE_WP_CRON is not defined, an event is late the moment its time passes and missed after five minutes. On a site that has defined it, WordPress assumes a system cron with a coarser tick and relaxes both: late after 15 minutes, missed after an hour. has_missed_cron() then walks the queue in time order and stops at the first event past the threshold, and that hook name is what the description prints:

recommended | A scheduled event has failed
The scheduled event, action_scheduler_run_queue, failed to run. Your site still works,
but this may indicate that scheduling posts or automated updates may not work as intended.

Every word of that is true, and the example is the one event on the list that is fine. action_scheduler_run_queue is WooCommerce’s Action Scheduler asking WP-Cron to tick it every minute, and Action Scheduler does not depend on the answer: it also spawns its own runner from admin-ajax.php on the shutdown of any admin request. That runner has fired between 411 and 438 times a day here since 3 September, and in the 24 hours before I read the queue it completed 447 actions with one past due. The scheduler that Site Health names as failed is the only one still running. The 54 events behind it in the queue are the ones that are not, and the description does not mention them because it was written to name one.

One more turn of the screw: the weekly wp_site_health_scheduled_check, which recomputes the numbers on the dashboard’s Site Health widget, is itself in the overdue set (due 14 September 21:15 UTC). The widget still shows the cached count, 20 good / 6 recommended / 1 critical. Run the direct tests now and there are 8 recommended, including the four I measured this week and one from WooCommerce’s own tests, plus one critical, also WooCommerce’s, which is a separate post.

Dating the stall from the queue itself

Horizontal bar chart titled 55 of 57 scheduled events are overdue, by who registered them: wp cron event list on mxchat.ai read 17 September 2026 07:57 UTC, oldest due 13 September 05:26 UTC, 98.5 hours ago. WordPress core 13 overdue; MxChat plus our own site tooling 12 overdue; Wordfence 11 overdue; Rank Math plus Pro 4 of 6 overdue; WooCommerce plus WooPayments 5; Jetpack bundled with WooPayments 4; Imagify 3; WPForms Lite 2; Action Scheduler every-minute event 1. Footnote: every hourly, twice-daily and daily event on the site is overdue; the two that are not are weekly Rank Math events whose slot had not arrived. Intervals: 1 minute times 1, 5 minutes times 2, hourly times 6, 12 hours times 5, daily times 26, weekly times 5, one-off times 12. DISABLE_WP_CRON is true, the account crontab is empty, nothing under /etc/cron names the site.

wp cron event list prints each event’s next scheduled time, and those times encode when the queue last ran. When WordPress runs a recurring event it reschedules it from the current time, not from the slot it missed: the next run becomes now plus whatever remains of the interval, so the shortest-interval event on the site pins the last pass to within its own interval. Here that is the every-minute Action Scheduler tick:

EventIntervalNext scheduled (UTC)So the last pass was
action_scheduler_run_queue1 minute13 Sep 05:26:49between 05:25:49 and 05:26:49
jetpack_sync_cron5 minutes13 Sep 05:28:22between 05:23:22 and 05:28:22
wordfence_hourly_cron1 hour13 Sep 05:37:50between 04:37 and 05:37
wp_version_check12 hours13 Sep 09:15:17between 12 Sep 21:15 and 13 Sep 09:15
mxchat_cleanup_old_transcriptsdaily14 Sep 03:00:00between 13 Sep 03:00 and 14 Sep 03:00

Four independent clocks agree with the one-minute event. The theme-update check stores the time it last ran: 05:28:29. Wordfence records the start of its scheduled scan and its daily cron: 05:28:31 and 05:28:35. MxChat’s model-liveness check writes the time it checked: 05:28:37. Every one of them is on 13 September, every one within eight seconds of the others, and none has moved since. That is the death certificate: 13 September 2026, 05:26 to 05:29 UTC.

The trap in this section is the update transients. update_core shows a last check at 07:53 this morning, which looks like a scheduler at work. It is not; my own wp core check-update refreshed it, and any visit to the Updates or Plugins screen does the same. The update checks are the one part of the queue that also runs outside cron, so their freshness says nothing about cron. Read the plugins’ own timestamps instead, or the shortest interval in the list.

Who had been running it

Timeline titled Every time WP-Cron ran on this site, 31 August to 17 September 2026: requests for wp-cron.php in the origin access logs on ports 80 and 443 and what answered them, times UTC. Three green dots where wp-cron.php answered 200 and the queue ran, each one a webshell wordlist sweep from an Azure address: 31 August 18:28, an 87-request sweep with 84 refused with 403; 12 September 05:59, a 515-request sweep, 221 self-requests followed; 13 September 05:26, a 417-request sweep, 83 self-requests followed. Two grey crosses where the same request over HTTPS was refused before PHP ran, 401 on 31 August and 404 on 13 September, no pass. A red band from 13 September 05:26 to 17 September 07:57 labelled no pass for 98.5 hours, 55 events overdue. Footnote: port-80 log covers 31 August 12:27 to 16 September 12:10 UTC, 34,004 lines; 18 addresses ran the same wordlist; 4 requests for wp-cron.php, all 200.

With DISABLE_WP_CRON true, the only way the queue runs is a direct request for wp-cron.php (or WP-CLI, which leaves no trace in the web log and which nobody here schedules). So I grepped for it. The HTTPS log has two hits in the window, and both were refused before PHP saw them: a 401 on 31 August, a 404 on 13 September. The plain-HTTP log, which I had not thought to read, has four hits, all 200, and three of them line up to the second with the passes above:

Pass (UTC)AddressUser agentThe sweep it was part ofWhat followed
31 Aug 18:28:5720.63.x.x (Microsoft Azure)none87 requests: c4.php, xmini4.php, reop3.php, lock360.php, wp-login.php…; 84 refused with 403, wp-cron.php answered 200the queue ran (Wordfence attack-data sync at 18:29)
12 Sep 05:59:254.224.x.x (Microsoft Azure)none515 requests: 406 × 404, 100 × 301, 9 × 200; the list runs min.php, n30n.php, m.php, wp-cron.php?, wawan.php, gigi.php, bnmtp.php221 self-requests over six minutes; Wordfence scan started
13 Sep 05:26:2752.141.x.x (Microsoft Azure)none417 requests: 268 × 404, 134 × 301, 14 × 200; the identical list in the identical order83 self-requests over three minutes; the last pass to date

Eighteen distinct addresses ran that wordlist against port 80 in the window (I counted the ones that asked for wp_filemanager.php, a file no honest client wants). Three of them reached wp-cron.php and got a 200, and the reason is not subtle: the other names on the list either do not exist here (404) or are refused by a filter on suspicious file names (403, on 31 August), and wp-cron.php is a real core file, so it answers. The same request class over HTTPS got a 401 and a 404, and the queue’s own clocks confirm neither ran anything: the every-minute event would have moved. So for at least seventeen days, the thing that decided whether this site’s housekeeping ran was which port a scanner chose.

Update, 18 September: the port itself turned out to be the larger story. The site address has been https for years, and WordPress still answered 34,847 plain-HTTP requests in eighteen days, redirecting exactly three routes and serving the rest, because the one Cloudflare toggle that closes port 80 was off. It stays off until a real cron job exists, for the reason this post explains.

I cannot tell you what the scanner wanted from wp-cron.php; a wordlist is a wordlist. I can tell you what it got: every scheduled task on the site, executed on its schedule rather than ours. The fourth 200 in the port-80 log is a POST from the server’s own address 25 seconds after the 12 September hit, with the kind of random user agent Wordfence uses for its loopbacks; I have not attributed it and it is not needed for the timeline.

What one pass does when it finally runs

Horizontal bar chart titled What one WP-Cron pass does here: requests the site makes to itself, from the origin log, requests from the server's own address in the minutes after each pass covering pingback discovery, the Wordfence scan and Jetpack sync. 12 September pass 05:59 to 06:05 UTC: 221 requests, 116 HEAD, 64 GET, 41 POST. 13 September pass 05:26 to 05:30 UTC: 83 requests, 48 HEAD, 30 GET, 5 POST. Waiting now, pingbacks alone: 193 plus requests, a lower bound; 41 posts carry _pingme, 187 internal and 6 external links, one HEAD each, a GET without X-Pingback, a POST per pingback. Footnote: each self-request is a full WordPress bootstrap at this origin, 1.6 seconds median measured 16 September; the 12 September pass also fired Wordfence's scheduled scan with 10 admin-ajax forks and two Jetpack sync spawns; restoring cron fires the backlog in one pass.

The log shows the pass as a burst of requests from the server to itself. On 13 September, from 05:26:30 to 05:29:47, there were 83: 48 HEAD, 30 GET and 5 POST. Most of the HEADs and GETs are do_pings, WordPress’s pingback client, discovering pingback endpoints for every link in every post published or edited since the previous pass, and since our posts link mostly to each other, that is the site fetching its own pages through its own front door, one full 1.6-second bootstrap per fetch. The POSTs are the pingbacks themselves, sent to our own xmlrpc.php, which returns 404 here because XML-RPC is off. Then Wordfence starts its scheduled scan and forks it about ten times through admin-ajax.php, and Jetpack spawns two sync requests. The 12 September pass, with more edits queued behind it, made 221 such requests over six minutes.

That is what a cron pass costs on this site, and it is the number to keep in mind when reading the fix section: restoring the scheduler does not run 55 events gently. It runs them in one pass, in queue order.

What stopped: the inventory

Fifty-five events, grouped by what is actually waiting on them. I checked each one’s side effects rather than taking the hook name’s word for it.

Event(s)OwnerIntervalWhat is waiting, measured this morning
wp_version_checkcore12 hWordPress 7.1.1 is scheduled to ship today (bug-fix release: 16 tickets, 22 Gutenberg PRs). WP_AUTO_UPDATE_CORE is true here, but wp_version_check() only fires wp_maybe_auto_update when wp_doing_cron() is true (wp-includes/update.php), so the release will sit until someone installs it by hand. The 7.0.4 security release did exactly this in August.
wp_update_plugins, wp_update_themescore12 h17 plugins have updates on the update server. auto_update_plugins is unset on this install, so none would auto-install regardless; the count refreshes on admin visits and WP-CLI anyway. The channels post covers which plugins here update themselves.
do_pingscoreone-off, queued 13 Sep 08:23:5641 posts carry _pingme and _encloseme: the four posts published since the 13th and 37 older posts edited since (WordPress re-queues a post’s pings on every save). 187 internal and 6 external links to discover. Pingbacks unsent, enclosures unscanned.
delete_expired_transientscoredaily186 of the 230 transient timeouts in wp_options are expired (81%), 2.3 KB of timeout rows. Trivial in bytes; expired transients are also deleted on read. It is the counter that shows the sweep is not running.
wp_site_health_scheduled_checkcoreweeklyThe dashboard widget’s count is stale (see above).
wp_scheduled_delete, wp_scheduled_auto_draft_delete, wp_privacy_delete_old_export_files, wp_privacy_personal_data_cleanup_requests, recovery_mode_clean_expired_keys, wp_delete_temp_updater_backups, wp_update_user_countscorehourly–weeklyNothing to do. 0 posts in trash (EMPTY_TRASH_DAYS is 7 here), 0 auto-drafts older than a week, no export files, no recovery keys. Seven of core’s thirteen events would have been no-ops.
wordfence_start_scheduled_scan ×3, wordfence_daily_cron, wordfence_hourly_cron, six one-offsWordfencehourly, daily, one-offThe last scheduled scan began 13 Sep 05:28:31 UTC; three scan starts are queued for the 13th, 14th and 15th at 17:22. The firewall is inline and does not need cron; the scan does. Attack-data reporting and the activity email are the one-offs.
jetpack_sync_cron, jetpack_sync_full_cron, jetpack_clean_nonces, jetpack_v2_heartbeatJetpack, bundled with WooPayments5 min – dailyThe scheduled path of the sync to WordPress.com. Jetpack Sync also sends on request shutdown, which I did not measure, so I will not claim the connection is stale.
wc_admin_daily, wc_admin_process_orders_milestone, wc_admin_unsnooze_admin_notes, woocommerce_marketplace_cron_fetch_promotions, validate_woopay_compatibilityWooCommercehourly – dailyAdmin housekeeping. Note what is not here: woocommerce_cleanup_sessions moved to Action Scheduler years ago and ran: 31 sessions in the table, 1 expired.
rank_math/links/internal_links, rank_math/redirection/clean_trashed, rank_math_cleanup_exports, rank_math/content-ai/update_promptsRank MathdailyInternal-link counts in the post list, trashed-redirect cleanup, export cleanup. Two weekly events (orphaned link status, Content AI plan) are the only ones not yet overdue.
imagify_sync_files, imagify_rating_event, imagify_update_library_size_calculations_eventImagifydaily, weeklyMoot: the account has been out of quota since August.
wpforms_email_summaries_cron, wpforms_weekly_entries_count_cronWPForms Liteone-off, 14 Sep 00:00The weekly summary email did not go out.
mxchat_model_liveness_checkMxChatdailychecked_at 13 Sep 05:28:37 UTC. The check that confirms each configured model still answers is four days stale (last result: nothing missing).
mxchat_cleanup_old_transcripts, mxchat_cleanup_old_images, mxchat_forms_wizard_cleanup, mxchat_forms_prune_webhook_log, mxchat_mcp_oauth_cleanup, mxchat_adv_truncation_scanMxChat core and four add-onsdaily, weeklyRetention and cleanup. On this install the transcript retention option is unset and the table holds 7 rows, so nothing was due for deletion; on a customer site with a 30-day retention set, this is the event that applies it, and it has not run since the 13th.
mxchat_core_docbot_sync_cron, mxchatpe_cleanup_sessionsour own site toolingdailyAn mu-plugin that re-syncs the documentation bot’s index, and a session cleanup for an internal editor tool. Neither ships to customers.
mxchat_daily_cleanup, mxchat_transcript_review_dailynobodydailyOrphans. has_action() returns false for both: no file on disk registers a callback any more (the plugin versions that scheduled them have moved on). WordPress will keep firing them into silence every day until someone runs wp cron event delete. Worth a look on any site that has been through a few plugin versions.
mxchat_session_store_maintenanceMxChatdailyRan anyway. mxchat_session_store_last_trim is 17 Sep 01:02 UTC: the add-on trims on an ordinary request when it is overdue. Same for the click-log sweep (16 Sep 08:22). Two of the twelve have a request-time fallback; the rest wait.

What did not stop

It matters to say this precisely, because “cron is dead” sounds like “the site is dead” and it is not. The page cache serves anonymous visitors without PHP. The chat widget, checkout, the REST API and the admin all run inside requests and do not care about the queue. Wordfence’s firewall inspects every request inline; only its scan is scheduled. Action Scheduler, which WooCommerce uses for everything from session cleanup to webhook delivery, has its own runner and is current to the minute. And two of MxChat’s cleanups checked their own timestamp and ran when a visitor came by, which is the pattern I would like the other ten to adopt.

What is dead is everything that trusts WP-Cron alone: core auto-updates, pingbacks, the security scan, the transient sweep, the plugin summaries, and every retention job. Those are exactly the things nobody notices for a week.

The fix, and why it is not a one-liner here

The right shape is the one wp-cron.php‘s own header comment describes: keep DISABLE_WP_CRON true so page views never spawn it, and have the operating system request it on a schedule. On a cPanel account that is one crontab line:

*/5 * * * * cd /home/ACCOUNT/public_html && /usr/local/bin/wp cron event run --due-now >/dev/null 2>&1

WP-CLI is better than curl-ing the URL because it does not depend on the web server letting the request through, which, as the 401 and 404 above show, is not guaranteed even for your own requests. Five minutes is as fine as anything here needs: the two Jetpack syncs run on five, and Action Scheduler’s one-minute tick has its own runner.

The reason it has not been done yet on this site, and the reason I am not doing it from this post, is the burst. The first pass would run 55 events back to back: pingback discovery for 41 posts (at least 193 self-requests at 1.6 s each), three queued Wordfence scan starts, a Jetpack full sync, the WPForms summary, the transient sweep and every retention job, in whatever order the queue holds them. On a shared host with no OPcache that is several minutes of the origin talking to itself, and the order is not one anyone chose. Draining it deliberately is cheap: list the queue, run the harmless ones by name (wp cron event run wp_version_check), run the scans at night, and decide whether 41 posts’ worth of pingbacks to a 404 endpoint are worth sending at all (wp cron event delete do_pings and clear the _pingme rows if not). Then add the crontab line and let the queue settle into its intervals. Once cron is real, it is also worth stopping Action Scheduler’s admin-ajax runner with add_filter( 'action_scheduler_allow_async_request_runner', '__return_false' ), which retires one of the most frequent callers in the admin-ajax log (about 420 requests a day here).

Two small things while you are in wp-config.php. define( 'WP_CRON', false ), which sits on the line above DISABLE_WP_CRON here, does nothing: no file in core reads a constant by that name, and it looks like someone trying to disable the scheduler twice. And WP_CRON_LOCK_TIMEOUT at 120 seconds is fine as it is.

Three commands to run on your own site

1. Read the queue. wp cron event list --fields=hook,next_run_gmt,next_run_relative,recurrence. If next_run_relative says “now” for an event whose recurrence is an hour, the scheduler has not passed in at least an hour. The shortest-interval event’s next run, minus its interval, is the last pass to within that interval. Without WP-CLI, the WP Crontrol plugin shows the same table.

2. Find the trigger. grep 'wp-cron.php' access_log, on both the HTTPS and the plain-HTTP log if your host keeps them separately. The timestamps that sit a few seconds before your plugins’ “last run” values are your real scheduler. If they belong to nobody you recognise, you have found the same thing I did.

3. Check who is supposed to run it. grep DISABLE_WP_CRON wp-config.php; crontab -l. If the first says true and the second is empty, your site is where ours was.

For chatbot sites specifically

A chatbot plugin accumulates things on purpose: transcripts, sessions, uploaded images, a model catalogue that needs re-checking, a documentation index that needs re-syncing. MxChat and its add-ons register daily and weekly events to keep those bounded (on this site, three from the core plugin and five from add-ons; the other four of the twelve mxchat_* rows are our own tooling and two orphans), and on a site with DISABLE_WP_CRON and no system cron, those events run when a stranger asks for wp-cron.php. The two that already run on an ordinary request when overdue, the session-store trim and the click-log sweep, kept working through the stall; the transcript retention did not, and that is the one with a privacy consequence, so whether it should carry the same fallback is now an open question on the product side. Meanwhile the operator’s check is the three commands above; the retention settings themselves live in the plugin’s settings screens, and the documentation is the place to start if you have never looked at them.

A WordPress site with WP-Cron disabled and no system cron is a site whose housekeeping happens whenever someone else asks for it. That is not a figure of speech. It is what the log says, three times, to the second.

Similar Posts