WP-Cron

This article is part of the WordPress guide. Read the introduction.

WP-Cron is WordPress’s userland implementation of cron. I’m assuming you know what cron is – it’s a system for executing scripts at a configured time. It is used to execute scheduled recurring tasks. Similarly to how WordPress nonces aren’t real nonces and salts aren’t real salts, WP-Cron isn’t a real cron. It’s an imitation of a cron limited by the nature of HTTP.

The limitation I’m referring to is the fact that a PHP process is only instantiated when someone sends a request to the server. Otherwise, WordPress code doesn’t run. This has a few interesting consequences. First of all, WP-Cron can’t promise execution at any exact time. What if you set your cron to run at 12 am on every Friday, but no one visits your website at that time? No PHP process would be created, WordPress code wouldn’t run, and your task wouldn’t be executed.

The way WP-Cron interval works is super confusing and you need a little more context to understand it. Bear with me and I promise you will get it by the end of this chapter.

Some examples of when you would need to use a cron:

  • Automatic background security scan.
  • Scheduled backup.
  • Periodic synchronization between your database and an external API.
  • Automatic periodic clearing of temporary files and cache.

How WP-Cron Works

WP-Cron utilizes the hook system. A cron job in WordPress is not a callback or a file. It’s an action. You register this action using wp_schedule_event() and passing its hook name and the interval. WordPress then checks if this interval has passed on every request and, if it has, it calls do_action(). The real “cron job” (the code that runs) is just the callback (or callbacks) you hook into this action.

Scheduling Events

The above was an oversimplification. Let’s get into the details. wp_schedule_event() schedules a recurring event. It accepts the initial timestamp (when the action should first be called), a recurrence string, the hook name, and an optional array of arguments to be passed to the callbacks hooked to the action. Here’s an example:

PHP
wp_schedule_event( time(), 'hourly', 'pgn_synchronize_database' );

This will schedule a task to call the “pgn_synchronize_database” action every hour starting right now (from the very next request). You would then hook a callback to this action with

add_action( ‘pgn_synchronize_database’, ‘pgn_callback’ ). The function pgn_callback() will be executed roughly every hour. Very important – a recurring event should be scheduled only once (e.g., on plugin activation). More on that later.

You might be surprised to see “hourly” instead of HOUR_IN_SECONDS or 3600. WP-Cron intervals are dependent on registered intervals defined as strings. Default intervals are “hourly”, “twicedaily”, “daily”, and “weekly”. You can define custom intervals by hooking into the “cron_schedules” filter, like so:

PHP
function pgn_add_fifteen_minute_interval( $schedules ) {
    $schedules['fifteen_minutes'] = [
        'interval' => 900, // 15 minutes in seconds
        'display'  => __( 'Every Fifteen Minutes' ),
    ];
    return $schedules;
}
add_filter( 'cron_schedules', 'pgn_add_fifteen_minute_interval' );

This will allow you to use the “fifteen_minutes” interval. You can see why this design choice was made in the above code example. Every interval has a “display” attribute. This attribute was originally created so that intervals can be displayed to admins in a human readable form. It’s better UX to choose between “Hourly” and “Daily” than it is to choose between “3600” and “86400”.

PS: There’s also wp_schedule_single_event(), which schedules a one-off, non-recurring event.

How Events Are Stored

Events are not defined purely in code. They are stored in the database, and they have to be because they are inherently stateful. They are just a serialized array of all scheduled actions. This array is stored as an option (in wp_options) with the name “cron”. Here’s an example of this array:

PHP
array(
    '1759001996' => array(
        'pgn_synchronize_database' => array(
            '40cd750bba9870f18aada2478b24840a' => array(
                'schedule' => 'hourly',
                'args'	=> array(),
                'interval' => 3600
            )
            // Other events at the same timestamp with the same hook, but with different args
        )
        // Other hooks scheduled to run at the same timestamp
    ),
    // Other events scheduled at different timestamps...
);

Okay, there’s a lot to untangle here. The first top-level key is the UNIX timestamp of when the event should next be run. WordPress checks these timestamps on every request. If it just so happens that it’s smaller than the current timestamp (i.e., it’s in the past), WordPress executes the event and updates the timestamp to when the event should next be executed.

The value of this timestamp is another associative array. Its keys are the hook names. Notice that if you scheduled two events at the same time but with different hooks, they would both be here. The value of that hook name is yet another array of arrays (it’s a lot of nesting, I know).

The key (40cd75…) is the MD5 hash of the “args” attribute. That’s the optional array of arguments you define when calling wp_schedule_event() that is passed to the action callbacks. It means that you can schedule the same event, at the same time, with the same interval, and only vary the args, and it will be treated and executed like a separate event. In our case, the MD5 hash is 40cd75… because that’s the hash of an empty array.

The values in the array under this MD5 hash are the details about this exact event. You can see that there are both the string “schedule” and the integer “interval”. The integer value is used as a fallback if the string isn’t defined with the “cron_schedules” filter (i.e., when it was there when the event was first scheduled but later got removed for some reason).

I said earlier that I would go back to why you should only schedule an event once. You should now be able to understand why. If you called wp_schedule_event() on every request (e.g., hooked to ‘init’) with time() as your initial timestamp, every request would schedule a new event.

If you received one request every second for 10 seconds, that would schedule 10 new events. They would all call the same hook with the same arguments – the only difference between them would be the timestamp (the difference would be 1). If it was an hourly event, the same event would be called 10 times every hour (and ten seconds). And that’s assuming you remove the offending code after 10 seconds. If you don’t, new events will keep getting scheduled on every request, eventually leading to your tasks being called every second.

The bottom line is – wp_schedule_event() adds the event to the option in the database. Once that happens, this event and its state (when it’s next going to be executed) lives and is updated entirely in that database. That’s why you should usually schedule events on plugin activation or user action and always check if this event isn’t already scheduled with wp_next_scheduled(), like:

PHP
// Only schedule if the event doesn't yet exist
if ( ! wp_next_scheduled( 'pgn_synchronize_database' ) ) {
    wp_schedule_event( time(), 'hourly', 'pgn_synchronize_database' );
}

WP-Cron Lifecycle

Now that you know what cron events are and how they work, it’s time to tie this entire system together by walking through it step by step.

1. An Event Is Scheduled

The administrator installs and activates a new plugin. This plugin calls wp_schedule_event() on its activation hook. The initial timestamp passed to wp_schedule_event() is time(), which is just the current UNIX timestamp. That’s the timestamp the event will be stored in the database with.

2. A User Visits The Website

Someone visits the website (it doesn’t matter who). WordPress executes its typical boot process and calls the ‘init’ action when it’s finished. The wp_cron() function, which is hooked to this action, executes.

3. WordPress Checks If There Are Any Events Scheduled To Run

wp_cron() retrieves the “cron” option containing the array of all scheduled events. This array is sorted by timestamps (the lowest timestamps are first, i.e., the ones most in the past). wp_cron() iterates over this array and if it finds any timestamp that is in the past, it calls spawn_cron() and stops iteration. Otherwise, if the first timestamp in this array is in the future, it means that no events are scheduled to run.

4. WordPress Checks & Acquires A Lock

WordPress checks if there is an existing valid lock. If there is, WP-Cron execution is aborted and the rest of the normal request lifecycle happens. If there isn’t, it acquires one and proceeds.

A lock is just a timestamp stored as a temporary value in the database (a transient). It’s used to prevent race conditions. If WP-Cron didn’t have a locking mechanism, a request made one second after another request would pose the risk of running the same event twice (beginning it before the first one updated the timestamp).

A lock is valid for a given amount of time defined by the WP_CRON_LOCK_TIMEOUT constant. It’s 60 seconds by default. This means that if a single cron job runs for more than 60 seconds, the lock will be deemed invalid and a new cron job may be started by a new request (with the old lock being overwritten by the new one).

5. WordPress Sends A Request To wp-cron.php

spawn_cron() utilizes the HTTP API to send a request to /wp-cron.php?doing_wp_cron=$timestamp. It uses the wp_remote_post() function. There are two extremely important args passed to this function:

  • ‘timeout’ => 0.01
  • ‘blocking’ => false

This means that the request is asynchronous. It doesn’t wait for a response. The lifecycle of WP-Cron for this request ends. WordPress handles all further processing as usual and returns a rendered page to the user.

The next step in WP-Cron’s lifecycle is now in wp-cron.php, which is a completely separate process running on the server. This is why WP-Cron doesn’t have a noticeable impact on the page’s load time for users that trigger it.

6. wp-cron.php Executes Scheduled Events

wp-cron.php starts off by checking if its lock (the doing_wp_cron timestamp query parameter) is the same as the lock in the database. If it is, it retrieves and iterates over all events in the “cron” option until it reaches one in the future (remember – they are ordered).

For every event iterated over that should be run:

  1. it reschedules the event with wp_reschedule_event();
  2. it unschedules the current event (with the timestamp in the past) with wp_unschedule_event();
  3. it calls do_action_ref_array( $hook, $event[‘args’] ), executing the callbacks hooked to this action.

wp_reschedule_event() creates a new event with all the same details but a different timestamp. It uses wp_schedule_event() internally. The timestamp is calculated so that it aligns with the initial timestamp. If you originally scheduled this event to run at exactly 6 pm, and it runs at 6:30 pm because there was no traffic, wp_reschedule_event() will schedule the next execution for 7 pm, not 7:30 pm.

WordPress cares more about preserving the exact schedule time than it does about preserving the interval between executions. This means that if your event is scheduled to run hourly at a full hour (i.e., if your initial timestamp was exactly 3:00 pm), and your first request between 3 and 4 pm comes at 3:58 pm, the event will be rescheduled to 4:00 pm – exactly two minutes after it was run.

This characteristic means that you can’t count on your interval at all. It is neither the maximum nor the minimum interval between executions. An event scheduled to run every 24 hours can not run for 47 hours, and then run twice in one hour. This is obviously an extreme example with literally 0 requests to the site in 23 hours, but you get the point.

7. wp-cron.php Checks/Deletes The Lock

A single action callback can take a long time. Let’s say you’re taking a full site backup and sending it to a remote storage. This may take longer than 60 seconds (which is the default lock timeout). After calling each event’s action, it checks if the lock in the database is still equal to the one it uses. If it’s not – it means that another cron request has taken over and is already working on executing the rest of the scheduled events. Execution is aborted.

If wp-cron.php manages to finish executing all scheduled events, the lock is deleted from the database and die() is called. Because there is no lock anymore, another cron job will be started on the next user request if there’s any event that is scheduled to run (with the timestamp in the past – somewhere between when the previous job started its execution and now).

Unscheduling Events

An event won’t unschedule itself when your plugin is deleted (duh!). Please don’t leave junk behind yourself. Always call wp_unschedule_event() for all your events on the deactivation hook.

Making WP-Cron More Reliable

WP-Cron is pretty unreliable, but given the constraints of the environment, it’s the best you can natively get. If your site has very few visitors, you will find that the difference between when events are scheduled to run and when they actually are run is large.

It can be problematic even if your site does have a lot of visitors. If your website is static, like a blog or a company brochure, you will almost certainly use full-page caching (we will cover that soon). This will circumvent the execution of the WordPress lifecycle, including the init hook. You can have thousands of visitors every hour, and yet, your cron will never run.

There is a common pattern used to make WP-Cron more reliable. You can create a cron job on your server’s system that triggers execution of WP-Cron. This can be just a bash/powershell command sending a request to /wp-cron.php (without the doing_wp_cron parameter – WordPress will acquire a new lock).

You can schedule this job to run every minute on your server, and because your OS’s scheduler is reliable and trustworthy, you can be sure that WP-Cron will be checked every minute – even if there’s no traffic. But even if you do that, WordPress will still check and attempt to execute WP-Cron on every request. To disable that (and save on some processing), you can put this in wp-config.php:

PHP
define( 'DISABLE_WP_CRON', true );

This will disable step 3 in the lifecycle, meaning that WordPress will not check for scheduled events on every request. That’s fine because your server-side script is hitting /wp-cron.php every minute anyway. I haven’t run any benchmarks, but if you opt to disable WP-Cron, you might save some memory and processing time by disabling autoload of the ‘cron’ option in the database. This will prevent it from being prefetched and then not utilized, and trust me – this option can get quite large.

Alternative Solutions

WP-Cron might not always be the best choice. There are some alternative cron systems like Cron-Control, Cavalcade, and Action Scheduler (which you can bundle with your plugin) that are more sophisticated and better optimized for large websites. These usually utilize separate database tables, allow for parallel execution, retries, and more. They are outside the scope of this guide – go check them out if your use case calls for a better cron (or job queue) system.

ALTERNATE_WP_CRON

There’s an alternative mode to the built-in WP-Cron. You can activate it by defining the ALTERNATE_WP_CRON constant as true (i.e., in wp-config.php). This mode works by redirecting the user who triggered the cron to the same URL with ?doing_wp_cron query parameter appended. A request to any URL with this query parameter will trigger a cron check.

Note that this absolutely will have a negative effect on the load time for this user, as the server needs to send a redirect and they have to request the new page again. This mode should only be used as a fallback if loopback requests (i.e., your server calling its own /wp-cron.php) are disabled in your hosting environment. I’m including it more as a fun fact so that you know what’s going on when you see a WordPress website redirecting you to URLs with ?doing_wp_cron.