WP-Cron
WP-Cron is a queue of due tasks checked on page loads, not a timer — which is why quiet sites run late.
wp_schedule_event( int $timestamp, string $recurrence, string $hook, array $args = array(), bool $wp_error = false ): bool|WP_ErrorWP-Cron schedules a hook to fire later. It is not cron. There is no daemon and no timer. Scheduled events live in the cron option as an array keyed by timestamp, and on every front-end request WordPress checks whether anything is overdue. If so it fires a non-blocking request to wp-cron.php, which runs the due hooks in a separate process.
That design has one consequence you must design around: a site with no traffic runs nothing. An event scheduled for 03:00 fires at whatever time the first visitor arrives afterwards. It can also fire late under caching, because a full-page cache hit may never reach PHP.
Scheduling
wp_schedule_event( $timestamp, $recurrence, $hook, $args, $wp_error ) takes a UTC Unix timestamp and one of the built-in recurrences: hourly, twicedaily, daily, or weekly (added in 5.4). Add your own through the cron_schedules filter. The $wp_error parameter arrived in 5.7. Schedule on plugin activation, not on every load, and check wp_next_scheduled() first. Unschedule with wp_clear_scheduled_hook() on deactivation.
Making it reliable
Set define( 'DISABLE_WP_CRON', true ); in wp-config.php to stop the page-load spawning, then have the system crontab call wp cron event run --due-now every minute. The queue is unchanged; only the trigger moves to something that actually keeps time.
Example
<?php
/**
* Schedule the sync on activation only. Calling wp_schedule_event() on
* every load would stack duplicate events.
*/
function scx_activate(): void {
if ( ! wp_next_scheduled( 'scx_sync_inventory' ) ) {
wp_schedule_event(
time() + HOUR_IN_SECONDS, // UTC Unix timestamp of the first run
'hourly',
'scx_sync_inventory'
);
}
}
register_activation_hook( __FILE__, 'scx_activate' );
/**
* Always tear the event down again, or it keeps firing a hook whose
* callback no longer exists.
*/
function scx_deactivate(): void {
wp_clear_scheduled_hook( 'scx_sync_inventory' );
}
register_deactivation_hook( __FILE__, 'scx_deactivate' );
/**
* The callback runs in a separate request with no user logged in,
* so nothing here may rely on current_user_can().
*/
function scx_sync_inventory(): void {
$response = wp_remote_get( 'https://api.example.com/stock', array( 'timeout' => 20 ) );
if ( is_wp_error( $response ) ) {
error_log( 'Inventory sync failed: ' . $response->get_error_message() );
return;
}
update_option( 'scx_last_sync', time(), false ); // false: do not autoload
}
add_action( 'scx_sync_inventory', 'scx_sync_inventory' );When to use it
- Pull an inventory feed every hour without a visitor having to trigger it.
- Email a reminder exactly 24 hours after an order is created.
- Diagnose why a daily cleanup job on a low-traffic site runs at odd hours.
- Move a caching site off page-load cron onto a system crontab.
More examples
A custom interval, and a one-off event
The cron_schedules filter must run before anything schedules against your interval, so register it on init or earlier. wp_schedule_single_event() is the right tool for per-record deferred work; always re-read the record inside the callback, because the world moves between scheduling and firing.
/**
* Built-in recurrences are hourly, twicedaily, daily and weekly (5.4+).
* Anything else you add yourself.
*/
function scx_add_schedules( array $schedules ): array {
$schedules['scx_five_minutes'] = array(
'interval' => 5 * MINUTE_IN_SECONDS, // seconds
'display' => __( 'Every five minutes', 'scx' ),
);
return $schedules;
}
add_filter( 'cron_schedules', 'scx_add_schedules' );
// A one-off job: send a reminder 24 hours after an order is placed.
// The args array is part of the event's identity, so each order gets
// its own independent event.
wp_schedule_single_event(
time() + DAY_IN_SECONDS,
'scx_send_reminder',
array( $order_id )
);
add_action(
'scx_send_reminder',
static function ( int $order_id ): void {
// Re-check state: a day has passed and the order may be cancelled.
if ( 'pending' === get_post_status( $order_id ) ) {
scx_email_reminder( $order_id );
}
}
);Replacing the page-load trigger with a real cron job
This is the standard production setup and the fix for "my daily job runs at random times". DISABLE_WP_CRON only stops WordPress spawning wp-cron.php from page loads; the schedule itself is untouched, so nothing else in your code changes. Note the constant does not block a direct request to wp-cron.php, which is what makes the curl fallback work.
// wp-config.php, above the "stop editing" line:
define( 'DISABLE_WP_CRON', true );
/*
Then in the system crontab (crontab -e). Preferred form, because it runs
the due events in one PHP process and exits non-zero on failure:
* * * * * cd /var/www/example.com && wp cron event run --due-now >/dev/null 2>&1
HTTP fallback where WP-CLI is unavailable:
* * * * * curl -s https://example.com/wp-cron.php?doing_wp_cron >/dev/null
Every minute is right: the crontab decides how often the queue is
CHECKED, while each event still fires only at its own scheduled time.
*/Seeing what is actually queued
Use this when a job seems to have stopped: it distinguishes "never scheduled" from "scheduled but overdue", which are different bugs. Remember the timestamps are UTC, so format them with gmdate() rather than date() or the times will look wrong by your site's offset.
// Every scheduled event, straight from the option WP-Cron reads.
$cron = _get_cron_array(); // array keyed by UTC timestamp
foreach ( $cron as $timestamp => $hooks ) {
foreach ( $hooks as $hook => $events ) {
printf(
"%s %s (%d queued)\n",
gmdate( 'Y-m-d H:i:s', $timestamp ), // timestamps are UTC
$hook,
count( $events )
);
}
}
// Next run of one hook, or false if it is not scheduled at all.
$next = wp_next_scheduled( 'scx_sync_inventory' );
// Same information from the command line:
// wp cron event list
// wp cron event run scx_sync_inventory
Discussion