Writing

WordPress cron not running? How WP-Cron works, and how to give the site a real one

· Kristoff Bertram

WP-Cron is not a clock. It is a check WordPress makes when somebody loads a page, which is why a scheduled post goes out twenty minutes late and a nightly backup runs at noon. The fix is a real cron job on the server. It takes one line in wp-config.php and one in the crontab.

How WP-Cron actually works

WordPress keeps its scheduled events, the “cron” array, in one option in the database: a list of hooks and the time each is due. Nothing watches that list. On every page load WordPress reads it, and if the earliest event is due, spawn_cron() sends a request from the site to itself, to wp-cron.php. That request runs the due events.

Three details from core matter in practice. The request is a loopback: wp_remote_post() to the site’s own wp-cron.php, with a 0.01 second timeout and no waiting for an answer, so the visitor is not held up. A lock, the doing_cron transient, stops two requests starting the same run within a minute. And on the front end the check happens on a page load, which means a page that never loads WordPress never makes it.

Why it runs late, or not at all

Late is the usual result, not lost: the event stays in the list and runs on the next check that gets through. That is fine for a scheduled post and not fine for a backup you were counting on at 03:00.

ALTERNATE_WP_CRON is the fallback for hosts where the loopback cannot work. Instead of a background request it redirects the visitor’s own request once, with doing_wp_cron in the address, and runs the events from that. It still needs a visit, it only acts on GET requests, and it puts a redirect in front of that visit. It fixes the loopback, not the visitors, so I would only use it when a server cron is not available.

Setting up a real server cron

Two steps, in this order. Order matters; the second is the one people forget.

1. Turn the page-load check off. In wp-config.php, above the line that says That’s all, stop editing:

define( 'DISABLE_WP_CRON', true );

This only stops spawn_cron(). wp-cron.php still works when something calls it, which is the point.

2. Run the due events from the server’s crontab. With WP-CLI:

*/15 * * * * cd /path/to/wordpress && wp cron event run --due-now --quiet --url=https://www.example.com

--due-now runs whatever is due and leaves the rest. --url tells WordPress which site this is: on multisite it picks the site whose events run, and on any install it is what settings that depend on the site’s address see, since a command line has no request to take it from. Leave it off and a cron job that works in a browser can quietly do nothing.

No WP-CLI? Call wp-cron.php over HTTP instead:

*/15 * * * * curl -fsS -o /dev/null "https://www.example.com/wp-cron.php?doing_wp_cron"

That is a real request through the web server, so it meets the same firewall and cache rules as a visitor. WP-CLI does not. Where both are possible, use WP-CLI.

Do not set DISABLE_WP_CRON without the crontab line. With the constant set and nothing calling cron, nothing runs your scheduled events at all: no scheduled posts, no backups, no cleanup, no update checks, and no error to tell you.

Which interval

Every 5 to 15 minutes is enough for nearly every site: the cron only has to be often enough that a due event waits a few minutes, not hours. Every minute works but means loading WordPress sixty times an hour for nothing on a typical site, and shared hosts will count it against your processes. If your events need minute precision, you have a queue problem, not a cron problem.

Where hosts put it

Each hosting panel calls it something different. I am describing, not showing screenshots of anyone’s account.

Some hosts do not offer WP-CLI in the cron environment, or run it with a different PHP than the site. If wp works in your shell and fails in the cron job, give the cron line the full path to it, and compare which php each one finds.

Checking it works

List what is scheduled and when it is due:

wp cron event list --url=https://www.example.com

A healthy site has no event whose next run is more than one cron interval in the past. Events a day overdue mean nothing is calling cron. And run it once by hand before you trust the crontab; whatever error the cron job would have swallowed shows up in front of you.

This part stands on its own. It is the same for scheduled posts, WooCommerce actions, update checks, and anything else registered with WP-Cron.

Scheduled backups on top of it

Freshet Backup schedules its run as an ordinary WP-Cron event, freshet_backup_run, so it is exactly as punctual as the cron under it. Behind that sits a catch-up: when a whole interval has gone by since a backup was due, the next time an administrator opens wp-admin the backup starts after the page has been sent, at most once per interval. If nothing has succeeded in twice the interval, a notice appears on every wp-admin page.

The catch-up has a limit I would rather say plainly. It needs an administrator to open wp-admin. On a site nobody logs in to, it does nothing, and the backup is as late as the cron. It is a net, not a schedule; a server cron is the schedule.

On a multisite network backups run from the main site only, and the catch-up and the settings screen are for a network administrator, not a site administrator. The cron line below belongs to the main site’s address.

To start a backup now from a shell, run the event:

wp cron event run freshet_backup_run --url=https://www.example.com

Keep the --url there too. The Dropbox connection only applies on the site’s own addresses, and a command without one names none.

To see which way your backups are being started, open Settings → Freshet Backup and read the end of the Last run row. Started on schedule means cron is doing its job. Started by the catch-up means WP-Cron did not run it and a visit to wp-admin did, which is the signal to set up the crontab line above. wp cron event list shows the next backup and whether it is overdue.

Since version 1.1.2 the same screen has a Server cron block, so you do not have to assemble the lines from this article. It prints the crontab line for your site, with its own path and address filled in, in the WP-CLI form and the curl form, plus the DISABLE_WP_CRON line for wp-config.php, every 15 minutes, and the warning not to add the constant without the crontab. Freshet Backup changes neither file; you copy the lines yourself. The block also tells you which way the schedule is driven: Run on page loads when DISABLE_WP_CRON is not set, Run from the server when it is set and the last backup started on schedule, and Run from the server, but nothing is calling it when it is set and the last backup still needed the catch-up. That last one means the crontab line is missing or not working, and the block opens itself to show it.