RE:NODE

WP-Cron vs real cron: fix missed WordPress schedules

By RE:NODE Team
17 min read


WP-Cron is not a scheduler. It is a check that runs on page loads: every time someone requests a page, WordPress looks at its list of jobs, and if one is overdue it fires off a background request to wp-cron.php to run it. No visitors means no jobs, and lots of visitors means a lot of checking. The fix most sites want is three steps: add define( 'DISABLE_WP_CRON', true ); to wp-config.php, call wp-cron.php (or wp cron event run --due-now) from a real cron every five minutes, and confirm with wp cron event list that the queue is draining. The rest of this post explains why that works, when it is not worth doing, and how to find out why a scheduled post sat there with "Missed schedule" next to it.

How WP-Cron actually works#

WordPress keeps every scheduled job in one database row: the cron option in the wp_options table, a serialized array keyed by Unix timestamp. Each entry names a hook (an action name such as wp_version_check or publish_future_post), its arguments, and, for repeating jobs, a recurrence such as hourly or daily. Plugins add their own entries with wp_schedule_event() or wp_schedule_single_event().

On every request, early in loading, WordPress calls wp_cron(). That function reads the cron option and asks one question: is the earliest timestamp in the past? If not, nothing happens and the cost is tiny. If something is due, it calls spawn_cron(), which does the following:

  1. Checks a lock, the doing_cron transient. If another spawn started less than WP_CRON_LOCK_TIMEOUT seconds ago (60 by default), it gives up.
  2. Sets the lock to the current time.
  3. Sends an HTTP POST from the server to itself - a loopback request - to https://your-site/wp-cron.php?doing_wp_cron=<timestamp>, with a timeout of 0.01 seconds and blocking set to false, so the visitor's page does not wait for it.
  4. The new request to wp-cron.php boots WordPress, walks the due jobs in order, runs each hook, reschedules the repeating ones and removes the single ones.
anything due?only if duerun, rescheduleVisitorany page requestWordPresschecks the cron optionLoopback requestPOST to itselfwp-cron.phpruns due hookswp_optionsthe cron row
What a single page view can set off

The design is clever for what it was built for: shared hosting in the 2000s, where most users could not edit a crontab at all. It needs no configuration and works on any host that lets PHP make an HTTP request to its own domain. Its weaknesses all follow from the trigger being a visitor.

  • Timing is approximate. A job "scheduled for 03:00" runs at the first page view after 03:00. On a quiet site that might be 07:40.
  • Quiet sites stall. A brochure site with ten visits a day runs its hourly jobs ten times a day, not twenty-four.
  • Busy sites check constantly. The check itself is cheap, but every spawned loopback is a full WordPress boot, and long jobs compete with real visitors for PHP workers.
  • Cached pages do not count. A visit served from a full-page cache never reaches PHP, so it never triggers WP-Cron. The better your caching, the less often cron runs.
  • The loopback can fail silently. If the server cannot reach its own public address, nothing errors on the page - jobs simply never run.

The built-in schedules and what runs on them#

Core registers four recurrences: hourly, twicedaily, daily and weekly. Plugins add more through the cron_schedules filter, which is why you see names like every_minute or five_minutes in a busy site's list. A fresh install schedules a handful of core jobs:

HookRecurrenceWhat it does
wp_version_checktwicedailyChecks for a new WordPress release
wp_update_pluginstwicedailyChecks plugin updates
wp_update_themestwicedailyChecks theme updates
wp_scheduled_deletedailyEmpties the trash after 30 days
delete_expired_transientsdailyClears expired transients
wp_privacy_delete_old_export_fileshourlyRemoves old personal-data exports
wp_site_health_scheduled_checkweeklyRuns the Site Health tests

Exact names and counts shift between WordPress versions, so treat the table as typical rather than definitive and check your own with wp cron event list. Automatic background updates also run through cron, as do scheduled posts: when you schedule a post, WordPress adds a single publish_future_post event at the post's time with the post ID as its argument.

Then come the plugins. Backup plugins schedule their runs, SEO plugins rebuild sitemaps, security plugins run scans, and WooCommerce brings Action Scheduler, its own queue that uses WP-Cron as one of its triggers and can carry thousands of pending actions on a store. On a site with thirty plugins it is normal to find sixty or more events, and sometimes a handful left behind by plugins that were deleted without cleaning up.

Registering your own job looks like this. The wp_next_scheduled() check matters - without it, every page load would add another copy of the event.

mu-plugins/nightly-cleanup.php
<?phpadd_action( 'mysite_nightly_cleanup', 'mysite_do_cleanup' );function mysite_do_cleanup() {    // Delete drafts older than 90 days, prune a log table, and so on.}if ( ! wp_next_scheduled( 'mysite_nightly_cleanup' ) ) {    wp_schedule_event( strtotime( 'tomorrow 03:15' ), 'daily', 'mysite_nightly_cleanup' );}

WP-Cron stores times as UTC timestamps. The time shown in the editor for a scheduled post uses the site timezone from Settings, General, and WordPress converts between the two, so the server's own timezone does not matter - but a server clock that is simply wrong does.

DISABLE_WP_CRON: turning off the page-load trigger#

The constant has a narrow, often misunderstood effect. It stops wp_cron() from spawning on page loads. It does not disable scheduled jobs, it does not remove anything from the cron option, and wp-cron.php still runs every due job when something requests it. You are removing the trigger, not the scheduler, which is why the constant is only half the change - the other half is giving WordPress a new trigger.

wp-config.php
define( 'DISABLE_WP_CRON', true );/* That's all, stop editing! Happy publishing. */

Put it above the "stop editing" line. Anything placed after wp-settings.php is loaded has no effect, and that is the most common reason people report the constant "not working".

When to leave WP-Cron alone

Not every site needs this. A small blog with steady traffic, no full-page cache in front of PHP and no time-sensitive jobs runs fine on the default, and adding a system cron is one more thing to break when you move hosts. The change is worth making when one of these is true:

  • Scheduled posts or emails need to go out on time, not "some time after".
  • Traffic is low or irregular, so jobs run hours late.
  • A full-page cache or a CDN serves most requests, so PHP rarely sees a visitor.
  • The site is busy, and you want cron work off the request path and out of the way of real visitors.
  • WooCommerce, a membership plugin or anything built on Action Scheduler is installed - their queues fall behind fast on page-load triggering.

Replacing it with a real cron#

A real trigger is anything that runs on a clock and can either make an HTTP request to wp-cron.php or run PHP inside the WordPress directory. Five-field cron syntax is the same everywhere; if */5 * * * * needs decoding, cron expressions explained takes it apart field by field.

Option 1: request wp-cron.php over HTTP

This works from anywhere - the same server, a different server, a small VDS you already run, or a hosted cron service - because it is just a web request. It is the only option when the hosting gives you no shell.

crontab -e
*/5 * * * * curl -fsS -o /dev/null --max-time 120 "https://example.com/wp-cron.php?doing_wp_cron"

Or with wget, which some minimal systems have instead:

bash
*/5 * * * * wget -q -O /dev/null --timeout=120 "https://example.com/wp-cron.php?doing_wp_cron"

The --max-time matters. Without it, a hung request can stay open indefinitely while cron starts another one five minutes later. The jobs still run inside a normal web request, so they are bound by the web server's and PHP's own time limits - a backup plugin that needs ten minutes may be cut off by max_execution_time or a proxy timeout. PHP ini settings that matter covers which limit is which.

Option 2: run it with WP-CLI

If you have a shell on the machine where WordPress lives, WP-CLI is the better trigger. It runs under the PHP command-line binary, which normally has no execution time limit, nothing goes through the web server, and there is no loopback to fail.

crontab -e (as the site's user)
*/5 * * * * cd /var/www/example.com && /usr/local/bin/wp cron event run --due-now --quiet

Run it as the same user that owns the WordPress files, not as root. WP-CLI refuses to run as root without --allow-root for good reason: files a job creates (cache files, uploads, backup archives) would be owned by root, and the web server could no longer change them. If one run can take longer than the interval, wrap the command in flock -n /tmp/wp-cron-example.lock so a second copy does not start on top of the first.

Calling php wp-cron.php directly from the WordPress directory also works and needs no WP-CLI, though WP-CLI gives you far better tools to see what happened.

How often to run it

SiteIntervalWhy
Blog or brochure site*/15 * * * *Nothing is urgent; scheduled posts at most 15 minutes late
Blog with timed posts or newsletters*/5 * * * *The usual default
WooCommerce or memberships* * * * *Action Scheduler queues and payment retries
Multisite network*/5 * * * * per siteEach site has its own cron option

Running every minute is cheap when nothing is due - the request checks a timestamp and exits. The cost appears only when a lot of work is waiting, and then you want it running promptly anyway. On multisite, remember that each site keeps its own queue: loop over wp site list --field=url and run wp cron event run --due-now --url=<site> for each, or request each site's wp-cron.php.

WP-CLI: seeing and running the queue#

WP-CLI is the quickest way to answer "is cron working". The first command shows everything waiting:

bash
$ wp cron event list --fields=hook,next_run_relative,recurrence+------------------------------------+------------------------+---------------+| hook                               | next_run_relative      | recurrence    |+------------------------------------+------------------------+---------------+| publish_future_post                | 3 hours 20 minutes ago | Non-repeating || wp_privacy_delete_old_export_files | 2 hours 1 minute ago   | 1 hour        || wp_version_check                   | 5 hours 12 minutes     | 12 hours      || wp_scheduled_delete                | 17 hours 4 minutes     | 1 day         |+------------------------------------+------------------------+---------------+

Anything showing "ago" is overdue. One or two events a minute or so late is normal; a column of events hours in the past means nothing is triggering cron at all. The useful commands:

CommandWhat it does
wp cron event listEvery scheduled event with its next run
wp cron event run --due-nowRuns every event whose time has passed
wp cron event run <hook>Runs one hook now, regardless of schedule
wp cron event schedule <hook> now hourlyAdds an event
wp cron event delete <hook>Removes the scheduled events for a hook
wp cron schedule listLists the available recurrences
wp cron testChecks that WP-Cron can spawn a loopback

Running a single hook with wp cron event run <hook> is the best way to debug a plugin's job: you see its errors and warnings in your terminal instead of losing them in a background request nobody watches. Add --debug for WP-CLI's own trace of what loaded.

wp cron test checks the page-load route - it sends the same loopback that spawn_cron() would, and it also tells you if DISABLE_WP_CRON is set. With the constant on, a warning there is expected; what matters then is whether your system cron is draining the list.

If you have no shell, the WP Crontrol plugin shows the same list inside the admin, lets you run an event immediately, and flags events whose hook has no code behind it any more. It is worth installing just for the diagnosis and removing afterwards.

Missed scheduled posts#

"Missed schedule" in the posts list means the publish_future_post event for that post was due and had not run. WordPress shows the label once the scheduled time has passed with the post still in the future status. Almost every case is one of these:

  1. No trigger. Nobody visited between the scheduled time and now, everything was served from cache, or DISABLE_WP_CRON is set with no replacement. The event is still in the queue; the next cron run will publish the post.
  2. The loopback fails. The server cannot reach its own domain. Common causes: the site's DNS points at a CDN or a proxy the server cannot reach from inside, hairpin NAT that drops connections from a server to its own public address, HTTP basic auth on a staging site, a security plugin or firewall that blocks requests to wp-cron.php, or a self-signed certificate on a local copy.
  3. The job died. An earlier job in the same run hit a fatal error or ran out of memory, and the rest of the queue never ran. One broken plugin hook can hold up everything behind it.
  4. The event vanished. The post is scheduled but wp cron event list shows no publish_future_post for it. This is rarer - races when two requests write the cron option at once, or a persistent object cache serving a stale copy of the options, are the explanations people usually find.

For the first three, fixing the trigger fixes the post: run wp cron event run --due-now and it publishes. For the fourth, open the post and publish it by hand, or set a new time and schedule it again, which creates a fresh event. Plugins that "publish missed scheduled posts" exist, and all they do is check for overdue future posts on page loads and publish them - a workaround for the trigger problem, not a fix, and redundant once a real cron is in place.

ALTERNATE_WP_CRON and other workarounds#

wp-config.php
define( 'ALTERNATE_WP_CRON', true );

ALTERNATE_WP_CRON exists for servers where the loopback cannot work and there is no way to add a real cron. Instead of WordPress calling itself, it redirects the visitor's browser to the same URL with ?doing_wp_cron= appended, and runs the due jobs during that request after sending the page. It does get jobs running, but at a price: a visible query string in the address bar, an extra redirect on whichever page view happens to trigger it, a URL variant that can end up shared or cached, and cron work still tied to visitor traffic. Treat it as a stopgap. If you can schedule an HTTP request to wp-cron.php from anywhere at all, do that instead and leave this constant off.

Two related constants are worth knowing. WP_CRON_LOCK_TIMEOUT sets the lock window in seconds; raising it does nothing useful for most sites, but some people lower it when page-load cron is left on and jobs must start more often. DISABLE_WP_CRON and ALTERNATE_WP_CRON should not both be set - with the first one on, the second never gets a chance to act.

wp-cron.php, caching and security#

Even with DISABLE_WP_CRON set, wp-cron.php answers anyone who requests it and runs whatever is due. That is usually harmless - if nothing is due, the request exits quickly - but an attacker or a broken bot hitting it in a loop makes PHP boot WordPress on every request. WordPress security hardening treats it alongside xmlrpc.php as an endpoint worth limiting. If your cron runs on the same machine through WP-CLI, nothing needs to reach wp-cron.php over HTTP at all, and you can deny it at the web server:

nginx
location = /wp-cron.php {    allow 127.0.0.1;    deny all;}

If an external machine calls it over HTTP, allow that machine's address instead of 127.0.0.1, or rate-limit the location rather than blocking it outright. Do not block it before the replacement trigger is working, or you are back to missed schedules.

Caching works in your favour once cron is on a clock. A full-page cache in front of WordPress, as described in WordPress speed and caching, means fewer requests reach PHP, which with page-load cron meant fewer chances to run jobs. With a real trigger the two stop interfering: the cache serves visitors and the cron serves the queue. Exclude wp-cron.php from any cache rule - most setups do by default because it carries a query string, but a CDN rule that caches "everything" can swallow it.

The memory side matters too. Cron requests run under WP_MAX_MEMORY_LIMIT (256M by default), not the lower front-end limit, so a heavy job can briefly use more than any page view does. If a site falls over every night around the same time, look at what cron runs at that hour before blaming traffic - how much RAM WordPress needs shows how to measure it.

Running WordPress cron on RE:NODE#

The WordPress line on RE:NODE web hosting installs WordPress on first start with wp-config.php already written for its own MySQL database, so DISABLE_WP_CRON goes into a file you can open straight away in the in-browser file manager or over SFTP. Out of the box, nothing is changed: WP-Cron runs on page loads exactly as it does anywhere else.

The panel has a Schedules tab that takes a standard cron expression and runs ordered tasks with delays between them - a console command, a backup or a power action (the Schedules guide walks through it). On a WordPress site that is the natural place for the jobs around WordPress rather than inside it: a nightly backup at an hour with no editors online, and a backup a few minutes before a scheduled bulk change. What it is not described as is a general way to request a URL, so for the wp-cron.php trigger itself use an HTTP cron from outside - another server you run, or any hosted cron service - with the curl line above pointed at your domain. Your site already serves HTTPS on its own domain through the plan's proxy slot, so the request is a normal one.

Two platform details matter for cron jobs specifically. CPU is a hard limit at the share the plan includes, so a heavy job on a small plan runs slowly rather than failing; and if a job pushes the server past its memory limit, the container is stopped and restarted clean instead of swapping, which ends that cron run part-way. If a nightly job is big enough to hit either, spread it out or move up a plan - background jobs on a small server covers the general pattern.

Troubleshooting checklist#

Events are hours overdue in `wp cron event list`. Nothing is triggering cron. If DISABLE_WP_CRON is set, your system cron is not running or not reaching the site - run the exact curl line by hand and look at the status code. If it is not set, the site has too little uncached traffic or the loopback fails; wp cron test tells you which.

The curl line returns 403 or a login page. A security plugin, a web application firewall or basic auth is blocking the request. Allow the caller's address, or switch to the WP-CLI trigger on the same machine.

The curl line returns 200 but nothing runs. Check the URL is the site's real address and not a redirect to www or HTTPS - add -L to follow it, or better, use the final URL directly. On multisite, check you are calling the right site.

One job never completes and everything behind it is late. Run that hook alone with wp cron event run <hook> and read the error. Fatal errors in background requests go to the PHP error log, not the screen, so enable WP_DEBUG_LOG while you look and turn it off afterwards.

The same job runs twice. Two triggers are active - page-load cron and a system cron, or two crontab lines left from an old setup. Set DISABLE_WP_CRON and keep exactly one schedule.

Hundreds of events for a plugin you deleted. The plugin left its hooks behind. Remove them with wp cron event delete <hook> or the WP Crontrol plugin. They are harmless but clutter the list and make real problems harder to spot.

FAQ#

Does DISABLE_WP_CRON stop scheduled posts from publishing?

Only if nothing else triggers cron. The constant removes the page-load trigger; the scheduled events stay in the queue and run as soon as something calls wp-cron.php or WP-CLI runs them. Pair it with a real cron and scheduled posts become more punctual, not less.

How often should a real cron call wp-cron.php?

Every five minutes suits most sites. Shops and anything using Action Scheduler do better at every minute, and a site with no time-sensitive work can use fifteen. A run with nothing due costs one short PHP request.

Can I run WP-Cron without shell access?

Yes. The trigger only has to make an HTTPS request to your site, so it can live anywhere: a hosted cron service, a VPS you already have, or a friend's home server. Use the curl line with a timeout and point it at the site's final URL.

Is ALTERNATE_WP_CRON safe to use?

It works and it is not dangerous, but it exposes a query string to visitors, adds a redirect, and still depends on traffic. Use it only when loopbacks fail and there is no way to schedule an outside request.

Why does Site Health say a scheduled event is late?

Site Health compares each event's time with now and flags those that are overdue. One late event usually means a slow trigger; a whole list of them means cron is not running. Check with wp cron event list and fix the trigger rather than the warning.

Does a page cache break WP-Cron?

It weakens it. Cached responses never reach PHP, so they never trigger cron. On a well-cached site with page-load cron, jobs can wait a long time for an uncached request. Moving to a real cron solves it completely.

Was this helpful?