How to replace WP-Cron with a real cron job (and why you should)

Replace WP-Cron with real cron to stop missed scheduled posts and late WooCommerce emails. Step-by-step crontab, WP-CLI and systemd timer setups that work.

Diagram: WP-Cron triggered by visits versus a server cron running on time

Key takeaways

  • WP-Cron only runs when someone visits, so quiet sites miss schedules and busy sites pay for extra requests.
  • The standard fix is to set DISABLE_WP_CRON to true and have the server run wp cron event run --due-now on a schedule.
  • Run the job as the site's own user, never root, and use flock to stop overlapping runs.
  • ZoPanel can switch a site to a server-run systemd timer every 5 minutes with one switch.

To replace WP-Cron with real cron, add define( 'DISABLE_WP_CRON', true ); to wp-config.php, then have the server run wp cron event run --due-now every few minutes as the site's own user, from crontab or a systemd timer. That's the whole fix. It matters because WP-Cron is not a real scheduler: WordPress only checks for due tasks when someone loads a page, so quiet sites miss scheduled posts and busy sites spend an extra PHP request on every check.

This guide explains how WP-Cron works, how to tell it's causing trouble, three ways to replace it (WP-CLI, an HTTP call, a systemd timer) with commands for Ubuntu and Debian, the mistakes to avoid, and how ZoPanel does it with one switch.

How does WP-Cron work?

WordPress has plenty of scheduled work: publishing scheduled posts, checking for updates, cleaning up temporary data, sending order emails, running backup or SEO plugin jobs. Because WordPress has to run on shared hosting where you cannot always configure cron, it fakes one:

  1. On every page load, WordPress checks its list of scheduled events (stored in wp_options).
  2. If something is due, it fires a background "loopback" request to https://your-site/wp-cron.php.
  3. That request runs the due events in a separate PHP process.

The WordPress developer handbook is explicit that, unless you disable it, WordPress keeps running WP-Cron on each page load even when a system scheduler exists. It's a sensible default for beginners, but it ties your schedule to your traffic, and that is the root of every problem below.

Why is WP-Cron slow or unreliable?

Quiet sites. No visits, no trigger. A post scheduled for 8:00 may go live at 10:30 when the first visitor shows up, and WordPress marks it "Missed schedule".

Page caching starves it. This one surprises people: pages served from an nginx or CDN cache never run PHP, so WordPress never gets the chance to check the schedule. The better your cache, the less often WP-Cron fires.

Failed loopback requests. The server must be able to call itself over HTTPS. Plenty of things break that: DNS that points through a proxy or not yet at the server, a firewall that blocks the server's own IP, password-protected directories, security plugins that block wp-cron.php, or a broken certificate. The "loopback request" test in WordPress's Site Health exists to catch exactly this.

Overhead on busy sites. Every time an event is due, another PHP request is spawned alongside the visitor's. If the domain resolves slowly, each call can tie up a PHP worker for 10 to 20 seconds. Across hundreds of sites on one server, those held workers add up to real memory and CPU pressure.

Heavy jobs inside a web request. Backup, newsletter and inventory-sync jobs running through wp-cron.php are subject to PHP-FPM and web server timeouts. Long jobs get cut off and start over next time.

How do you know WP-Cron is the problem?

Typical symptoms:

  • Scheduled posts showing "Missed schedule".
  • Order emails or membership password resets arriving hours late.
  • In WooCommerce, Status → Scheduled Actions lists many "Past-due" actions.
  • Site Health reports a failed loopback request or a failed scheduled event.
  • The access log is full of POST /wp-cron.php on a busy site.

With SSH access, WP-CLI gives the quickest answer. Run it in the site's directory as the user that owns the site:

wp cron test               # tests WP-Cron's spawning
wp cron event list         # events and their next run time

Events whose "next run" is long in the past mean WP-Cron is not being triggered regularly.

How to replace WP-Cron with real cron

Step 1: Stop visit-triggered cron

Open wp-config.php and add this line above /* That's all, stop editing! */:

define( 'DISABLE_WP_CRON', true );

This only stops visits from triggering cron. Events are still scheduled; they just wait for something to run them. So don't stop here: without step 2, your site will never run scheduled tasks again.

Step 2: Let the server run due events

Three options, in the order we recommend them.

Option A: WP-CLI (recommended). No HTTP, no web server timeouts, no dependence on DNS or SSL. Edit the crontab of the user that owns the site (not root):

sudo crontab -u webuser -e

Add:

*/5 * * * * cd /home/webuser/public_html && flock -n /tmp/wpcron-webuser.lock /usr/local/bin/wp cron event run --due-now --quiet

What each part does:

  • */5 * * * *: every 5 minutes.
  • cd ...: into the WordPress directory (or use --path=).
  • flock -n: if the previous run is still going, skip this one instead of overlapping.
  • wp cron event run --due-now: runs every due event. The WP-CLI documentation notes it respects WordPress's doing_cron lock to prevent overlapping runs.

The path to wp depends on how you installed it; check with which wp. On multisite, add --network to cover every subsite.

Option B: Call wp-cron.php over HTTP. For when WP-CLI isn't available:

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

This still depends on DNS, SSL and PHP-FPM timeouts, but at least the schedule no longer depends on visitors. Use the https:// URL if the site forces HTTPS, since curl does not follow redirects without -L.

Option C: A systemd timer. On Ubuntu 24.04 and Debian 12, a timer gives you clearer logs (journalctl), no overlapping runs (a service runs one instance at a time) and RandomizedDelaySec to spread the load across many sites. Create /etc/systemd/system/wpcron-example.service:

[Unit]
Description=WordPress cron for example.com

[Service]
Type=oneshot
User=webuser
WorkingDirectory=/home/webuser/public_html
ExecStart=/usr/local/bin/wp cron event run --due-now --quiet
Nice=10
IOSchedulingClass=idle

and /etc/systemd/system/wpcron-example.timer:

[Unit]
Description=Run WordPress cron for example.com every 5 minutes

[Timer]
OnCalendar=*:0/5
RandomizedDelaySec=60
Persistent=true

[Install]
WantedBy=timers.target

Enable it:

sudo systemctl daemon-reload
sudo systemctl enable --now wpcron-example.timer
systemctl list-timers | grep wpcron

Step 3: Verify

sudo -u webuser wp --path=/home/webuser/public_html cron event list

After a few cycles, "next run" should always be in the near future. Schedule a test post ten minutes out and check that it goes live on time.

Every 1, 5 or 15 minutes?

Interval Good for Notes
1 minute Busy shops, large email queues Costs the most; multiply by the number of sites on the server
5 minutes Most sites, including small and mid-size WooCommerce stores Good balance; scheduled posts are at most 5 minutes late
15 minutes Blogs, brochure sites The interval used in the WordPress handbook's example

A note on WooCommerce: its Action Scheduler queue asks WP-Cron to run it every minute. With a 5-minute cron, queued actions wait up to 5 minutes, which is fine for most stores. Very busy shops can add a separate cron line that runs wp action-scheduler run every minute; the Action Scheduler docs recommend WP-CLI for large queues.

On a server with many sites, don't let every job fire at minute 0, 5, 10. Spread them out (with RandomizedDelaySec or offset minutes in crontab) to avoid synchronized load spikes.

Common mistakes

  • Running cron as root. WP-CLI refuses to run as root without --allow-root, for good reason: cache and log files created by the job end up owned by root and the site can no longer write them, and a malicious plugin would run with root rights. Always use the site's user.
  • Setting DISABLE_WP_CRON and forgetting the cron job. The site silently stops all scheduled work. You find out a week later when there are no backups.
  • Putting DISABLE_WP_CRON after the "stop editing" line. By then wp-settings.php has loaded and the constant has no effect.
  • No lock. A heavy job (a backup) runs past 5 minutes, the next run piles on, and the server struggles.
  • Treating ALTERNATE_WP_CRON as the fix. It redirects a visitor to run cron and is meant only for broken loopbacks with no other option. It still depends on visits.

Replacing WP-Cron with one switch in ZoPanel

In ZoPanel, open the website, go to the WordPress tab and, in the Maintenance card, turn on Server runs scheduled tasks (every 5 min, not on visits). As the WordPress Toolkit documentation explains, ZoPanel then:

  1. sets DISABLE_WP_CRON to true in wp-config.php;
  2. creates a systemd timer that runs wp cron event run --due-now every 5 minutes as the hosting account, inside that account's resource limits and at low CPU and disk priority. Sites are spread across the interval, so a server hosting hundreds of them never sees all their jobs at once.

New WordPress installs made through ZoPanel use this mode from the start. Turning it off removes the timer, and removes DISABLE_WP_CRON only if ZoPanel added it; a site that disabled WP-Cron on its own keeps its setting.

This is also why ZoPanel's page cache doesn't cause missed schedules: cached pages no longer need to trigger WP-Cron because the server takes care of it. In our measurements of how many WordPress sites a 4 GB VPS can host, letting the server run scheduled tasks is one of the tips we drew from packing hundreds of sites onto one machine. See PHP performance for how quiet sites' PHP is put to sleep while their scheduled tasks keep running.

Conclusion

WP-Cron made sense for old-school shared hosting, but on a VPS you have real cron, and you should use it. Two steps are enough: stop visit-triggered cron, then have the server run wp cron event run --due-now on a schedule as the site's user. Scheduled posts go out on time, emails leave when they should, and every page view gets slightly lighter.

If you'd rather not create crontabs site by site, ZoPanel's Free plan includes the full WordPress Toolkit for 10 websites. See pricing when you need more.

Frequently asked questions

Will disabling WP-Cron break my plugins?

No, as long as you replace it with server cron. Plugins schedule events exactly as before; only the trigger changes. If you disable WP-Cron without a replacement, every plugin's scheduled tasks stop.

Why do my scheduled posts say "Missed schedule"?

Because nothing ran PHP at the right moment, usually because the site is quiet or its pages are served from cache. A failing loopback request to wp-cron.php causes the same symptom. A server cron job every 5 minutes fixes both.

Should I use wget/curl or WP-CLI in cron?

WP-CLI whenever you can: it runs directly in PHP CLI, outside the web server, so it doesn't depend on DNS, SSL or PHP-FPM timeouts. Calling wp-cron.php with curl is the fallback when WP-CLI isn't installed.

What if I'm on shared hosting without SSH?

Most hosting panels have a cron jobs page in the interface; use the curl or wget call to wp-cron.php from option B. If your host runs ZoPanel, just turn on Server runs scheduled tasks on the WordPress tab.

Does running cron every minute slow the server down?

For a single site, barely: with nothing due, the command exits quickly. On a server with hundreds of sites, each run still loads WordPress, so 5 minutes is a better balance, with jobs spread across the interval.