WP-Cron Deep Dive: Reliable Scheduled Tasks in WordPress

WordPress’s built-in task scheduler, WP-Cron, works on a pseudo-cron model: there is no daemon running on a schedule — instead, every HTTP request to a WordPress page checks whether any scheduled events are due by reading the cron option from wp_options, and if a due event is found WordPress spawns a non-blocking HTTP request to /wp-cron.php in the background to run the event outside the original request’s response cycle. This model works well on sites with continuous traffic — a scheduled event due at 14:00 runs the next time any visitor loads a page after 14:00. On low-traffic sites — an API, staging environment, or intranet — events can be delayed by hours if no visitors arrive to trigger the pseudo-cron check. The cron option is a serialised array of scheduled events keyed by Unix timestamp, and reading it on every page load is a performance concern on high-traffic sites — this is why DISABLE_WP_CRON exists. Setting define( 'DISABLE_WP_CRON', true ); in wp-config.php stops WordPress from spawning the background request on every page load, but it also stops all scheduled events unless a real server cron job calls /wp-cron.php on a regular schedule. The recommended production setup is to disable WP-Cron and add a real system cron job that calls wp cron event run --due-now via WP-CLI every minute — this provides reliable timing, eliminates the per-request cron check overhead, and separates scheduled-task execution from the web request lifecycle. WordPress ships three built-in schedule intervals: hourly, twicedaily, and daily — custom intervals are registered with the cron_schedules filter and can specify any recurrence in seconds. Events are scheduled with wp_schedule_event( $timestamp, $recurrence, $hook ) for recurring events and wp_schedule_single_event( $timestamp, $hook, $args ) for one-time events; the scheduled hook name is fired by WordPress when the event runs, so any function attached to it with add_action() executes. The Action Scheduler post covered the queue-based approach for large job volumes; WP-Cron’s native scheduler is appropriate for light scheduled tasks like daily reports, cache invalidation, and periodic API polling.

Problem: A WordPress staging site runs a daily import that pulls data from an external API at 03:00 server time. On most nights the import runs 2–5 hours late because no visitors hit the staging site between midnight and morning, so WP-Cron never fires. The same site also shows noticeable response-time spikes on high-traffic mornings because the cron check spawns background HTTP requests on every page load.

Solution: Set DISABLE_WP_CRON in wp-config.php, add a real system crontab entry that calls WP-CLI every minute, and register a custom 5-minute interval for the import hook — giving reliable execution completely independent of visitor traffic.

// wp-config.php — disable the pseudo-cron HTTP spawn on every page load
define( 'DISABLE_WP_CRON', true );

// ── plugin.php: register a custom cron interval ───────────────────────────────
add_filter( 'cron_schedules', function( array $schedules ): array {
    $schedules['every_five_minutes'] = [
        'interval' => 5 * MINUTE_IN_SECONDS,
        'display'  => __( 'Every 5 Minutes', 'myplugin' ),
    ];
    $schedules['every_fifteen_minutes'] = [
        'interval' => 15 * MINUTE_IN_SECONDS,
        'display'  => __( 'Every 15 Minutes', 'myplugin' ),
    ];
    return $schedules;
} );

// ── Schedule the daily import on plugin activation ────────────────────────────
register_activation_hook( __FILE__, 'myplugin_schedule_events' );

function myplugin_schedule_events(): void {
    // Schedule for 03:00 server time tonight (adjust timezone offset as needed)
    $first_run = strtotime( 'today 03:00:00' );
    if ( $first_run < time() ) {
        $first_run = strtotime( 'tomorrow 03:00:00' );
    }

    if ( ! wp_next_scheduled( 'myplugin_daily_import' ) ) {
        wp_schedule_event( $first_run, 'daily', 'myplugin_daily_import' );
    }
    if ( ! wp_next_scheduled( 'myplugin_cache_warmup' ) ) {
        wp_schedule_event( time(), 'every_fifteen_minutes', 'myplugin_cache_warmup' );
    }
}

// ── Clean up on deactivation ──────────────────────────────────────────────────
register_deactivation_hook( __FILE__, function(): void {
    wp_clear_scheduled_hook( 'myplugin_daily_import' );
    wp_clear_scheduled_hook( 'myplugin_cache_warmup' );
} );

// ── Event callbacks ───────────────────────────────────────────────────────────
add_action( 'myplugin_daily_import', function(): void {
    // Long-running tasks: set a PHP time limit for this cron context
    set_time_limit( 300 );

    $response = wp_remote_get( 'https://api.example.com/products', [
        'timeout' => 60,
        'headers' => [ 'Authorization' => 'Bearer ' . get_option( 'myplugin_api_key' ) ],
    ] );

    if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
        // Log failure — Action Scheduler is better for retry logic on failure
        error_log( 'myplugin_daily_import failed: ' . wp_remote_retrieve_response_message( $response ) );
        return;
    }

    $data = json_decode( wp_remote_retrieve_body( $response ), true );
    if ( json_last_error() !== JSON_ERROR_NONE || empty( $data ) ) return;

    update_option( 'myplugin_last_import_data', $data, false ); // autoload=false
    update_option( 'myplugin_last_import_time', time(), false );
} );

add_action( 'myplugin_cache_warmup', function(): void {
    delete_transient( 'myplugin_homepage_data' );
    // Rebuild the transient immediately so the next visitor gets a cache hit
    myplugin_get_homepage_data(); // function sets the transient internally
} );

# ── Real server cron: replace pseudo-cron with WP-CLI ─────────────────────────

# Edit the web server user's crontab (www-data on Ubuntu, daemon on macOS/XAMPP)
sudo crontab -u www-data -e

# Add this line — runs every minute, output to a log file
* * * * * /usr/local/bin/wp --path=/var/www/helloadmin cron event run --due-now     >> /var/log/wp-cron.log 2>&1

# ── Verify scheduled events with WP-CLI ───────────────────────────────────────
wp cron event list --fields=hook,next_run_relative,schedule --format=table

# Check for missed events (events whose next_run timestamp is in the past)
wp cron event list --format=json |     python3 -c "
import json, sys, time
events = json.load(sys.stdin)
now = time.time()
missed = [e for e in events if e.get('time', now+1) < now]
print(f'{len(missed)} missed event(s)')
for e in missed:
    print(f'  {e["hook"]} — scheduled {int(now - e["time"])}s ago')
"

# Run all due events immediately (useful for testing)
wp cron event run --due-now

# Run a specific hook manually (bypass schedule)
wp cron event run myplugin_daily_import

# Delete a scheduled event
wp cron event delete myplugin_cache_warmup

NOTE: When DISABLE_WP_CRON is true and no server cron job is configured, all scheduled events stop running — including WordPress core events like wp_version_check, wp_update_plugins, wp_update_themes, and the delete_expired_transients cleanup. Always verify the system cron is working after enabling DISABLE_WP_CRON with wp cron event list — if the next_run_relative column shows “1 hour ago” or longer for events that should have fired, the system cron is not triggering. On shared hosting without cron access, leave DISABLE_WP_CRON at its default (false) and accept the pseudo-cron limitations; alternatively, use a free external service like EasyCron or Cron-job.org to make an HTTP GET to https://yourdomain.com/wp-cron.php?doing_wp_cron every minute from their servers.