Complete WordPress Cron Guide: Hooks, Methods & Best Practices

Table of Contents

Master the WordPress WP-Cron system with production-ready examples, security best practices, and performance optimization techniques. This comprehensive guide covers everything from basic scheduling to advanced custom intervals and debugging.

What is WP-Cron and How It Works

WordPress WP-Cron is a time-based task scheduling system that executes scheduled events without requiring server-level cron access. Unlike traditional Unix cron that runs at specific intervals regardless of traffic, WP-Cron is triggered by page loads, making it both a strength and potential limitation.

How WP-Cron Execution Works

When a user visits your WordPress site, the system checks if any scheduled tasks are due. If tasks are ready to execute, WordPress spawns a non-blocking HTTP request to wp-cron.php that processes the events in the background. This approach has several implications:

  • Traffic Dependency: Low-traffic sites may experience delayed execution since cron relies on page visits
  • Non-Blocking Execution: The visitor doesn’t wait for cron tasks to complete
  • No Server Access Required: Works on any hosting environment without shell access
  • Potential Overlap: Multiple simultaneous visits can trigger duplicate executions

WP-Cron vs System Cron

Feature WP-Cron System Cron
Execution Trigger Page loads Time-based intervals
Server Access Required No Yes (SSH/cPanel)
Precision Approximate (traffic-dependent) Exact timing
Low-Traffic Sites Can be unreliable Reliable
Resource Usage Distributed across requests Dedicated execution

Basic Event Scheduling

WordPress provides three primary functions for scheduling events, each serving different use cases. Understanding when to use each method is crucial for efficient task management.

Single Event Scheduling

Use wp_schedule_single_event() when you need a task to run exactly once at a specific time. Common use cases include sending delayed emails, processing one-time imports, or scheduling content publication.

PHP
/**
 * Schedule a single event to run in 2 hours
 *
 * @return void
 */
function schedule_delayed_notification() {
    // Ensure we don't duplicate the event
    $timestamp = time() + ( 2 * HOUR_IN_SECONDS );
    $hook      = 'send_delayed_email_notification';
    $args      = array( 'user_id' => 42 );

    // Check if already scheduled
    if ( ! wp_next_scheduled( $hook, $args ) ) {
        wp_schedule_single_event( $timestamp, $hook, $args );
    }
}
add_action( 'init', 'schedule_delayed_notification' );

/**
 * Handle the scheduled event
 *
 * @param int $user_id User ID to send notification to.
 * @return void
 */
function send_delayed_email_notification( $user_id ) {
    $user = get_userdata( $user_id );
    
    if ( ! $user ) {
        return;
    }

    $to      = $user->user_email;
    $subject = 'Your Scheduled Notification';
    $message = 'This email was sent via WP-Cron 2 hours after scheduling.';
    $headers = array( 'Content-Type: text/html; charset=UTF-8' );

    wp_mail( $to, $subject, $message, $headers );
}
add_action( 'send_delayed_email_notification', 'send_delayed_email_notification', 10, 1 );

Recurring Event Scheduling

For tasks that need to repeat at regular intervals, use wp_schedule_event(). This is ideal for cleanup tasks, data synchronization, report generation, and automated maintenance.

PHP
/**
 * Schedule a recurring daily cleanup task
 *
 * @return void
 */
function schedule_daily_cleanup() {
    $timestamp  = strtotime( 'tomorrow 2:00am' ); // Run at 2 AM daily
    $recurrence = 'daily';
    $hook       = 'perform_daily_cleanup';

    if ( ! wp_next_scheduled( $hook ) ) {
        wp_schedule_event( $timestamp, $recurrence, $hook );
    }
}
add_action( 'wp', 'schedule_daily_cleanup' );

/**
 * Perform daily cleanup operations
 *
 * @return void
 */
function perform_daily_cleanup() {
    global $wpdb;

    // Delete transients older than 30 days
    $wpdb->query(
        $wpdb->prepare(
            "DELETE FROM {$wpdb->options} 
            WHERE option_name LIKE %s 
            AND option_name NOT LIKE %s 
            AND option_value < %d", $wpdb->esc_like( '_transient_timeout_' ) . '%',
            $wpdb->esc_like( '_transient_timeout_feed_' ) . '%',
            time() - ( 30 * DAY_IN_SECONDS )
        )
    );

    // Clear old post revisions (keep last 5)
    $wpdb->query(
        "DELETE p1 FROM {$wpdb->posts} p1
        INNER JOIN {$wpdb->posts} p2 
        WHERE p1.post_parent = p2.post_parent
        AND p1.post_type = 'revision'
        AND p1.ID < p2.ID AND ( SELECT COUNT(*) FROM {$wpdb->posts} p3
            WHERE p3.post_parent = p1.post_parent
            AND p3.post_type = 'revision'
            AND p3.ID > p1.ID
        ) >= 5"
    );

    do_action( 'after_daily_cleanup' );
}
add_action( 'perform_daily_cleanup', 'perform_daily_cleanup' );

Core Scheduling Methods

wp_schedule_single_event()

Schedules a hook to run once at a specific timestamp. This event will only execute a single time and then remove itself from the schedule.

Parameters:

  • $timestamp (int) – Unix timestamp when the event should run
  • $hook (string) – Action hook to execute
  • $args (array) – Optional arguments to pass to the hook callback
  • $wp_error (bool) – Return WP_Error on failure (WordPress 5.1+)
PHP
/**
 * Schedule post auto-save 5 minutes from now
 *
 * @param int $post_id Post ID to auto-save.
 * @return bool|WP_Error True on success, WP_Error on failure.
 */
function schedule_post_auto_save( $post_id ) {
    $timestamp = time() + ( 5 * MINUTE_IN_SECONDS );
    $hook      = 'auto_save_post_draft';
    $args      = array( 'post_id' => absint( $post_id ) );

    return wp_schedule_single_event( $timestamp, $hook, $args, true );
}

/**
 * Auto-save post draft
 *
 * @param int $post_id Post ID.
 * @return void
 */
function auto_save_post_draft( $post_id ) {
    // Verify post exists and is not published
    $post = get_post( $post_id );
    
    if ( ! $post || 'publish' === $post->post_status ) {
        return;
    }

    // Save current content
    $post_data = array(
        'ID'           => $post_id,
        'post_content' => $post->post_content,
        'post_title'   => $post->post_title,
    );

    wp_update_post( $post_data );
    
    update_post_meta( $post_id, '_last_auto_save', current_time( 'mysql' ) );
}
add_action( 'auto_save_post_draft', 'auto_save_post_draft', 10, 1 );

wp_schedule_event()

Schedules a recurring event using predefined or custom intervals. The event will continue to execute at the specified interval until manually unscheduled.

Parameters:

  • $timestamp (int) – Unix timestamp for first execution
  • $recurrence (string) – How often the event should recur (hourly, twicedaily, daily, or custom)
  • $hook (string) – Action hook to execute
  • $args (array) – Optional arguments passed to hook callback
  • $wp_error (bool) – Return WP_Error on failure (WordPress 5.1+)
PHP
/**
 * Schedule hourly API data sync
 *
 * @return void
 */
function schedule_api_sync() {
    $timestamp  = time();
    $recurrence = 'hourly';
    $hook       = 'sync_external_api_data';

    if ( ! wp_next_scheduled( $hook ) ) {
        wp_schedule_event( $timestamp, $recurrence, $hook );
    }
}
add_action( 'init', 'schedule_api_sync' );

/**
 * Sync data from external API
 *
 * @return void
 */
function sync_external_api_data() {
    $api_url  = 'https://api.example.com/data';
    $response = wp_remote_get(
        $api_url,
        array(
            'timeout' => 15,
            'headers' => array(
                'Authorization' => 'Bearer ' . get_option( 'api_token' ),
            ),
        )
    );

    if ( is_wp_error( $response ) ) {
        error_log( 'API Sync Failed: ' . $response->get_error_message() );
        return;
    }

    $body = wp_remote_retrieve_body( $response );
    $data = json_decode( $body, true );

    if ( json_last_error() !== JSON_ERROR_NONE ) {
        error_log( 'API Sync JSON Error: ' . json_last_error_msg() );
        return;
    }

    update_option( 'synced_api_data', $data );
    update_option( 'last_api_sync', current_time( 'timestamp' ) );
}
add_action( 'sync_external_api_data', 'sync_external_api_data' );

wp_next_scheduled()

Returns the timestamp of the next scheduled occurrence of an event, or false if not scheduled. Essential for preventing duplicate scheduling.

PHP
<?php
/**
 * Check if backup is already scheduled
 *
 * @return int|false Next scheduled timestamp or false.
 */
function get_next_backup_time() {
    $hook = 'perform_database_backup';
    $args = array( 'backup_type' => 'full' );

    return wp_next_scheduled( $hook, $args );
}

/**
 * Display next backup time in admin
 *
 * @return void
 */
function display_next_backup_notice() {
    $next_backup = get_next_backup_time();

    if ( $next_backup ) {
        $time_diff = human_time_diff( time(), $next_backup );
        echo '<div class="notice notice-info">';
        echo 'Next backup scheduled in ' . esc_html( $time_diff ) . '
';
        echo '</div>';
    }
}
add_action( 'admin_notices', 'display_next_backup_notice' );

Custom Cron Intervals

WordPress includes three default intervals: hourly (3600 seconds), twicedaily (43200 seconds), and daily (86400 seconds). For custom timing requirements, you can add your own intervals using the cron_schedules filter.

Adding Custom Intervals

Custom intervals are useful for specific business requirements like every 5 minutes, weekly, or monthly schedules. Always use the cron_schedules filter hook to add intervals.

PHP
/**
 * Add custom cron intervals
 *
 * @param array $schedules Existing cron schedules.
 * @return array Modified schedules array.
 */
function add_custom_cron_intervals( $schedules ) {
    // Every 5 minutes
    $schedules['every_five_minutes'] = array(
        'interval' => 5 * MINUTE_IN_SECONDS,
        'display'  => esc_html__( 'Every 5 Minutes', 'textdomain' ),
    );

    // Every 15 minutes
    $schedules['every_fifteen_minutes'] = array(
        'interval' => 15 * MINUTE_IN_SECONDS,
        'display'  => esc_html__( 'Every 15 Minutes', 'textdomain' ),
    );

    // Every 30 minutes
    $schedules['every_thirty_minutes'] = array(
        'interval' => 30 * MINUTE_IN_SECONDS,
        'display'  => esc_html__( 'Every 30 Minutes', 'textdomain' ),
    );

    // Weekly
    $schedules['weekly'] = array(
        'interval' => 7 * DAY_IN_SECONDS,
        'display'  => esc_html__( 'Once Weekly', 'textdomain' ),
    );

    // Monthly (approximate - 30 days)
    $schedules['monthly'] = array(
        'interval' => 30 * DAY_IN_SECONDS,
        'display'  => esc_html__( 'Once Monthly', 'textdomain' ),
    );

    return $schedules;
}
add_filter( 'cron_schedules', 'add_custom_cron_intervals' );

Using Custom Intervals

Once registered, custom intervals can be used exactly like built-in intervals with wp_schedule_event().

PHP
/**
 * Schedule frequent cache warming every 5 minutes
 *
 * @return void
 */
function schedule_cache_warming() {
    $timestamp  = time();
    $recurrence = 'every_five_minutes';
    $hook       = 'warm_site_cache';

    if ( ! wp_next_scheduled( $hook ) ) {
        wp_schedule_event( $timestamp, $recurrence, $hook );
    }
}
add_action( 'init', 'schedule_cache_warming' );

/**
 * Warm critical pages cache
 *
 * @return void
 */
function warm_site_cache() {
    $critical_urls = array(
        home_url( '/' ),
        home_url( '/about/' ),
        home_url( '/contact/' ),
        home_url( '/products/' ),
    );

    foreach ( $critical_urls as $url ) {
        wp_remote_get(
            $url,
            array(
                'timeout'   => 10,
                'blocking'  => false,
                'sslverify' => false,
            )
        );
    }
}
add_action( 'warm_site_cache', 'warm_site_cache' );

Weekly Report Generation Example

PHP
/**
 * Schedule weekly analytics report
 *
 * @return void
 */
function schedule_weekly_report() {
    // Run every Monday at 9 AM
    $next_monday = strtotime( 'next Monday 9:00am' );
    $recurrence  = 'weekly';
    $hook        = 'generate_weekly_analytics_report';

    if ( ! wp_next_scheduled( $hook ) ) {
        wp_schedule_event( $next_monday, $recurrence, $hook );
    }
}
add_action( 'wp', 'schedule_weekly_report' );

/**
 * Generate and email weekly analytics report
 *
 * @return void
 */
function generate_weekly_analytics_report() {
    global $wpdb;

    $last_week = strtotime( '-7 days' );
    
    // Get post stats
    $post_count = $wpdb->get_var(
        $wpdb->prepare(
            "SELECT COUNT(*) FROM {$wpdb->posts} 
            WHERE post_status = 'publish' 
            AND post_type = 'post' 
            AND post_date >= %s",
            gmdate( 'Y-m-d H:i:s', $last_week )
        )
    );

    // Get comment stats
    $comment_count = $wpdb->get_var(
        $wpdb->prepare(
            "SELECT COUNT(*) FROM {$wpdb->comments} 
            WHERE comment_approved = '1' 
            AND comment_date >= %s",
            gmdate( 'Y-m-d H:i:s', $last_week )
        )
    );

    // Build email
    $admin_email = get_option( 'admin_email' );
    $subject     = sprintf( 'Weekly Analytics Report - %s', gmdate( 'F j, Y' ) );
    $message     = sprintf(
        "Weekly Report Summary:\n\n" .
        "New Posts: %d\n" .
        "New Comments: %d\n" .
        "Report Period: %s to %s\n",
        $post_count,
        $comment_count,
        gmdate( 'M j', $last_week ),
        gmdate( 'M j' )
    );

    wp_mail( $admin_email, $subject, $message );
}
add_action( 'generate_weekly_analytics_report', 'generate_weekly_analytics_report' );

Cron Hooks and Actions

WordPress provides several hooks that allow you to interact with the cron system at various stages of execution. Understanding these hooks enables advanced functionality like logging, error handling, and execution control.

Core Cron Hooks

cron_schedules Filter

Already covered above, this filter allows you to add custom intervals to the available schedules.

schedule_event Action Hook

Fires immediately after an event is scheduled. Useful for logging or triggering additional actions when events are created.

PHP
/**
 * Log when events are scheduled
 *
 * @param object $event Scheduled event object.
 * @return void
 */
function log_cron_event_scheduled( $event ) {
    if ( ! defined( 'WP_DEBUG' ) || ! WP_DEBUG ) {
        return;
    }

    error_log(
        sprintf(
            'Cron Event Scheduled: %s | Next Run: %s | Interval: %s',
            $event->hook,
            gmdate( 'Y-m-d H:i:s', $event->timestamp ),
            isset( $event->schedule ) ? $event->schedule : 'single'
        )
    );
}
add_action( 'schedule_event', 'log_cron_event_scheduled', 10, 1 );

unschedule_event Action Hook

Fires when an event is unscheduled, allowing you to perform cleanup or logging operations.

PHP
/**
 * Log when events are unscheduled
 *
 * @param int    $timestamp Timestamp of the event.
 * @param string $hook      Action hook of the event.
 * @param array  $args      Arguments passed to the hook.
 * @return void
 */
function log_cron_event_unscheduled( $timestamp, $hook, $args ) {
    if ( ! defined( 'WP_DEBUG' ) || ! WP_DEBUG ) {
        return;
    }

    error_log(
        sprintf(
            'Cron Event Unscheduled: %s | Was scheduled for: %s',
            $hook,
            gmdate( 'Y-m-d H:i:s', $timestamp )
        )
    );
}
add_action( 'unschedule_event', 'log_cron_event_unscheduled', 10, 3 );

wp_cron Action Hook

Fires before WP-Cron executes scheduled events. Perfect for logging, monitoring, or initialization tasks that should run before any cron job.

PHP
/**
 * Log WP-Cron execution start
 *
 * @return void
 */
function log_wp_cron_execution() {
    $crons = _get_cron_array();
    $count = 0;

    if ( is_array( $crons ) ) {
        foreach ( $crons as $timestamp => $cronhooks ) {
            $count += count( $cronhooks );
        }
    }

    error_log(
        sprintf(
            'WP-Cron Started | Total scheduled events: %d | Timestamp: %s',
            $count,
            gmdate( 'Y-m-d H:i:s' )
        )
    );
}
add_action( 'wp_cron', 'log_wp_cron_execution' );

pre_schedule_event Filter

Filters an event before it is scheduled. Return false to prevent the event from being scheduled.

PHP
/**
 * Prevent scheduling events during maintenance mode
 *
 * @param null|bool|WP_Error $pre   Pre-schedule value (null to continue).
 * @param object             $event Event object.
 * @param bool               $wp_error Whether to return WP_Error.
 * @return null|bool|WP_Error
 */
function prevent_cron_during_maintenance( $pre, $event, $wp_error ) {
    // Check if maintenance mode is active
    if ( file_exists( ABSPATH . '.maintenance' ) ) {
        if ( $wp_error ) {
            return new WP_Error(
                'maintenance_mode',
                __( 'Cannot schedule events during maintenance mode.', 'textdomain' )
            );
        }
        return false;
    }

    return $pre;
}
add_filter( 'pre_schedule_event', 'prevent_cron_during_maintenance', 10, 3 );

Advanced Real-World Examples

Complete Backup System with Static Class

A production-ready backup system that handles scheduling, execution, cleanup, and error handling with proper WPCS compliance.

PHP
/**
 * Database Backup Manager
 *
 * Handles automated database backups with cleanup and error handling.
 *
 * @package YourPlugin
 */
class Database_Backup_Manager {

    /**
     * Backup directory path
     *
     * @var string
     */
    private static $backup_dir = '';

    /**
     * Initialize backup system
     *
     * @return void
     */
    public static function init() {
        self::$backup_dir = WP_CONTENT_DIR . '/backups/';

        // Create backup directory if it doesn't exist
        if ( ! file_exists( self::$backup_dir ) ) {
            wp_mkdir_p( self::$backup_dir );
        }

        // Schedule daily backups at 3 AM
        add_action( 'init', array( __CLASS__, 'schedule_backups' ) );
        add_action( 'perform_daily_database_backup', array( __CLASS__, 'perform_backup' ) );
        add_action( 'cleanup_old_backups', array( __CLASS__, 'cleanup_old_backups' ) );
    }

    /**
     * Schedule backup events
     *
     * @return void
     */
    public static function schedule_backups() {
        // Schedule daily backup
        if ( ! wp_next_scheduled( 'perform_daily_database_backup' ) ) {
            wp_schedule_event(
                strtotime( 'tomorrow 3:00am' ),
                'daily',
                'perform_daily_database_backup'
            );
        }

        // Schedule weekly cleanup
        if ( ! wp_next_scheduled( 'cleanup_old_backups' ) ) {
            wp_schedule_event(
                strtotime( 'next Sunday 4:00am' ),
                'weekly',
                'cleanup_old_backups'
            );
        }
    }

    /**
     * Perform database backup
     *
     * @return bool True on success, false on failure.
     */
    public static function perform_backup() {
        global $wpdb;

        $filename = sprintf(
            'db-backup-%s-%s.sql',
            sanitize_title( get_bloginfo( 'name' ) ),
            gmdate( 'Y-m-d-H-i-s' )
        );

        $filepath = self::$backup_dir . $filename;

        // Get all tables
        $tables = $wpdb->get_results( 'SHOW TABLES', ARRAY_N );

        if ( empty( $tables ) ) {
            error_log( 'Database backup failed: No tables found' );
            return false;
        }

        $sql_dump = '';

        // Loop through tables
        foreach ( $tables as $table ) {
            $table_name = $table[0];

            // Get CREATE TABLE statement
            $create_table = $wpdb->get_row( "SHOW CREATE TABLE `{$table_name}`", ARRAY_N );
            $sql_dump    .= "\n\n" . $create_table[1] . ";\n\n";

            // Get table data
            $rows = $wpdb->get_results( "SELECT * FROM `{$table_name}`", ARRAY_A );

            if ( ! empty( $rows ) ) {
                foreach ( $rows as $row ) {
                    $values = array_map(
                        function( $value ) use ( $wpdb ) {
                            return is_null( $value ) ? 'NULL' : "'" . $wpdb->_real_escape( $value ) . "'";
                        },
                        array_values( $row )
                    );

                    $sql_dump .= sprintf(
                        "INSERT INTO `%s` VALUES (%s);\n",
                        $table_name,
                        implode( ', ', $values )
                    );
                }
            }
        }

        // Write to file
        $result = file_put_contents( $filepath, $sql_dump );

        if ( false === $result ) {
            error_log( 'Database backup failed: Could not write to file' );
            return false;
        }

        // Compress backup
        if ( function_exists( 'gzencode' ) ) {
            $compressed = gzencode( $sql_dump, 9 );
            file_put_contents( $filepath . '.gz', $compressed );
            unlink( $filepath ); // Remove uncompressed version
        }

        // Log success
        update_option( 'last_backup_time', current_time( 'timestamp' ) );
        update_option( 'last_backup_file', $filename );

        return true;
    }

    /**
     * Cleanup old backups
     *
     * @param int $days_to_keep Number of days to retain backups.
     * @return void
     */
    public static function cleanup_old_backups( $days_to_keep = 30 ) {
        $files = glob( self::$backup_dir . 'db-backup-*.sql*' );

        if ( empty( $files ) ) {
            return;
        }

        $cutoff_time = time() - ( $days_to_keep * DAY_IN_SECONDS );
        $deleted     = 0;

        foreach ( $files as $file ) {
            if ( filemtime( $file ) < $cutoff_time ) { unlink( $file ); $deleted++; } } if ( $deleted > 0 ) {
            error_log( sprintf( 'Cleaned up %d old backup files', $deleted ) );
        }
    }

    /**
     * Get backup status
     *
     * @return array Backup status information.
     */
    public static function get_backup_status() {
        $last_backup = get_option( 'last_backup_time', 0 );
        $next_backup = wp_next_scheduled( 'perform_daily_database_backup' );

        return array(
            'last_backup'      => $last_backup ? gmdate( 'Y-m-d H:i:s', $last_backup ) : 'Never',
            'next_backup'      => $next_backup ? gmdate( 'Y-m-d H:i:s', $next_backup ) : 'Not scheduled',
            'last_backup_file' => get_option( 'last_backup_file', 'None' ),
        );
    }
}

// Initialize the backup system
Database_Backup_Manager::init();

Email Queue System with Rate Limiting

A sophisticated email queuing system that prevents overwhelming SMTP servers by rate-limiting email sends.

PHP
/**
 * Email Queue Manager
 *
 * Manages queued emails with rate limiting to prevent SMTP throttling.
 *
 * @package YourPlugin
 */
class Email_Queue_Manager {

    /**
     * Queue table name
     *
     * @var string
     */
    private static $table_name = '';

    /**
     * Maximum emails per batch
     *
     * @var int
     */
    private static $batch_size = 50;

    /**
     * Initialize email queue system
     *
     * @return void
     */
    public static function init() {
        global $wpdb;
        self::$table_name = $wpdb->prefix . 'email_queue';

        // Create database table
        add_action( 'plugins_loaded', array( __CLASS__, 'create_table' ) );

        // Schedule queue processing every 5 minutes
        add_action( 'init', array( __CLASS__, 'schedule_processing' ) );
        add_action( 'process_email_queue', array( __CLASS__, 'process_queue' ) );
    }

    /**
     * Create queue table
     *
     * @return void
     */
    public static function create_table() {
        global $wpdb;

        $charset_collate = $wpdb->get_charset_collate();

        $sql = "CREATE TABLE IF NOT EXISTS " . self::$table_name . " (
            id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
            recipient varchar(255) NOT NULL,
            subject text NOT NULL,
            message longtext NOT NULL,
            headers longtext,
            attachments longtext,
            priority tinyint(1) DEFAULT 5,
            status varchar(20) DEFAULT 'pending',
            attempts int(11) DEFAULT 0,
            created_at datetime DEFAULT CURRENT_TIMESTAMP,
            sent_at datetime DEFAULT NULL,
            PRIMARY KEY  (id),
            KEY status (status),
            KEY priority (priority),
            KEY created_at (created_at)
        ) $charset_collate;";

        require_once ABSPATH . 'wp-admin/includes/upgrade.php';
        dbDelta( $sql );
    }

    /**
     * Schedule queue processing
     *
     * @return void
     */
    public static function schedule_processing() {
        if ( ! wp_next_scheduled( 'process_email_queue' ) ) {
            wp_schedule_event( time(), 'every_five_minutes', 'process_email_queue' );
        }
    }

    /**
     * Add email to queue
     *
     * @param string $to          Recipient email address.
     * @param string $subject     Email subject.
     * @param string $message     Email message.
     * @param array  $headers     Optional email headers.
     * @param array  $attachments Optional file attachments.
     * @param int    $priority    Priority (1-10, lower is higher priority).
     * @return int|false Queue ID on success, false on failure.
     */
    public static function add_to_queue( $to, $subject, $message, $headers = array(), $attachments = array(), $priority = 5 ) {
        global $wpdb;

        $result = $wpdb->insert(
            self::$table_name,
            array(
                'recipient'   => sanitize_email( $to ),
                'subject'     => sanitize_text_field( $subject ),
                'message'     => wp_kses_post( $message ),
                'headers'     => maybe_serialize( $headers ),
                'attachments' => maybe_serialize( $attachments ),
                'priority'    => absint( $priority ),
                'status'      => 'pending',
            ),
            array( '%s', '%s', '%s', '%s', '%s', '%d', '%s' )
        );

        return $result ? $wpdb->insert_id : false;
    }

    /**
     * Process email queue
     *
     * @return void
     */
    public static function process_queue() {
        global $wpdb;

        // Get pending emails ordered by priority and creation time
        $emails = $wpdb->get_results(
            $wpdb->prepare(
                "SELECT * FROM " . self::$table_name . "
                WHERE status = 'pending'
                AND attempts < 3 ORDER BY priority ASC, created_at ASC LIMIT %d", self::$batch_size ) ); if ( empty( $emails ) ) { return; } $sent_count = 0; $error_count = 0; foreach ( $emails as $email ) { // Update attempt count $wpdb->update(
                self::$table_name,
                array( 'attempts' => $email->attempts + 1 ),
                array( 'id' => $email->id ),
                array( '%d' ),
                array( '%d' )
            );

            // Send email
            $headers     = maybe_unserialize( $email->headers );
            $attachments = maybe_unserialize( $email->attachments );

            $sent = wp_mail(
                $email->recipient,
                $email->subject,
                $email->message,
                $headers,
                $attachments
            );

            if ( $sent ) {
                // Mark as sent
                $wpdb->update(
                    self::$table_name,
                    array(
                        'status'  => 'sent',
                        'sent_at' => current_time( 'mysql' ),
                    ),
                    array( 'id' => $email->id ),
                    array( '%s', '%s' ),
                    array( '%d' )
                );
                $sent_count++;
            } else {
                // Mark as failed if max attempts reached
                if ( $email->attempts + 1 >= 3 ) {
                    $wpdb->update(
                        self::$table_name,
                        array( 'status' => 'failed' ),
                        array( 'id' => $email->id ),
                        array( '%s' ),
                        array( '%d' )
                    );
                }
                $error_count++;
            }

            // Rate limiting: small delay between sends
            usleep( 100000 ); // 0.1 second
        }

        // Log results
        error_log(
            sprintf(
                'Email Queue Processed: %d sent, %d failed',
                $sent_count,
                $error_count
            )
        );
    }

    /**
     * Get queue statistics
     *
     * @return array Queue statistics.
     */
    public static function get_stats() {
        global $wpdb;

        return array(
            'pending' => $wpdb->get_var( "SELECT COUNT(*) FROM " . self::$table_name . " WHERE status = 'pending'" ),
            'sent'    => $wpdb->get_var( "SELECT COUNT(*) FROM " . self::$table_name . " WHERE status = 'sent'" ),
            'failed'  => $wpdb->get_var( "SELECT COUNT(*) FROM " . self::$table_name . " WHERE status = 'failed'" ),
        );
    }
}

// Initialize email queue
Email_Queue_Manager::init();

Content Expiration System

Automatically expire posts, pages, or custom post types after a specified period.

PHP
<?php
/**
 * Content Expiration Manager
 *
 * Handles automatic expiration of posts with custom expiry dates.
 *
 * @package YourPlugin
 */
class Content_Expiration_Manager {

    /**
     * Initialize expiration system
     *
     * @return void
     */
    public static function init() {
        add_action( 'init', array( __CLASS__, 'schedule_expiration_check' ) );
        add_action( 'check_expired_content', array( __CLASS__, 'check_and_expire_content' ) );
        add_action( 'add_meta_boxes', array( __CLASS__, 'add_expiration_meta_box' ) );
        add_action( 'save_post', array( __CLASS__, 'save_expiration_date' ), 10, 2 );
    }

    /**
     * Schedule hourly expiration check
     *
     * @return void
     */
    public static function schedule_expiration_check() {
        if ( ! wp_next_scheduled( 'check_expired_content' ) ) {
            wp_schedule_event( time(), 'hourly', 'check_expired_content' );
        }
    }

    /**
     * Check and expire content
     *
     * @return void
     */
    public static function check_and_expire_content() {
        $args = array(
            'post_type'      => 'any',
            'posts_per_page' => -1,
            'post_status'    => 'publish',
            'meta_query'     => array(
                array(
                    'key'     => '_expiration_date',
                    'value'   => current_time( 'timestamp' ),
                    'compare' => '<=', 'type' => 'NUMERIC',
                ),
            ),
        );

        $expired_posts = get_posts( $args );

        if ( empty( $expired_posts ) ) {
            return;
        }

        foreach ( $expired_posts as $post ) {
            $expiration_action = get_post_meta( $post->ID, '_expiration_action', true );

            switch ( $expiration_action ) {
                case 'draft':
                    wp_update_post(
                        array(
                            'ID'          => $post->ID,
                            'post_status' => 'draft',
                        )
                    );
                    break;

                case 'trash':
                    wp_trash_post( $post->ID );
                    break;

                case 'delete':
                    wp_delete_post( $post->ID, true );
                    break;

                case 'private':
                    wp_update_post(
                        array(
                            'ID'          => $post->ID,
                            'post_status' => 'private',
                        )
                    );
                    break;
            }

            // Log expiration
            error_log(
                sprintf(
                    'Content Expired: Post ID %d (%s) - Action: %s',
                    $post->ID,
                    $post->post_title,
                    $expiration_action
                )
            );

            // Clear expiration meta
            delete_post_meta( $post->ID, '_expiration_date' );
            delete_post_meta( $post->ID, '_expiration_action' );
        }
    }

    /**
     * Add expiration meta box
     *
     * @return void
     */
    public static function add_expiration_meta_box() {
        add_meta_box(
            'content_expiration',
            __( 'Content Expiration', 'textdomain' ),
            array( __CLASS__, 'render_meta_box' ),
            array( 'post', 'page' ),
            'side',
            'high'
        );
    }

    /**
     * Render expiration meta box
     *
     * @param WP_Post $post Current post object.
     * @return void
     */
    public static function render_meta_box( $post ) {
        wp_nonce_field( 'save_expiration_date', 'expiration_date_nonce' );

        $expiration_date   = get_post_meta( $post->ID, '_expiration_date', true );
        $expiration_action = get_post_meta( $post->ID, '_expiration_action', true );

        $date_value = $expiration_date ? gmdate( 'Y-m-d\TH:i', $expiration_date ) : '';
        ?>
        
            <label for="expiration_date"><?php esc_html_e( 'Expiration Date:', 'textdomain' ); ?></label>
            <input type="datetime-local" id="expiration_date" name="expiration_date" value="<?php echo esc_attr( $date_value ); ?>" style="width: 100%;">
        

        
            <label for="expiration_action"><?php esc_html_e( 'Action on Expiration:', 'textdomain' ); ?></label>
            <select id="expiration_action" name="expiration_action" style="width: 100%;">
                <option value="draft" <?php selected( $expiration_action, 'draft' ); ?>><?php esc_html_e( 'Move to Draft', 'textdomain' ); ?></option>
                <option value="trash" <?php selected( $expiration_action, 'trash' ); ?>><?php esc_html_e( 'Move to Trash', 'textdomain' ); ?></option>
                <option value="private" <?php selected( $expiration_action, 'private' ); ?>><?php esc_html_e( 'Make Private', 'textdomain' ); ?></option>
                <option value="delete" <?php selected( $expiration_action, 'delete' ); ?>><?php esc_html_e( 'Permanently Delete', 'textdomain' ); ?></option>
            </select>
        

        <?php
    }

    /**
     * Save expiration date
     *
     * @param int     $post_id Post ID.
     * @param WP_Post $post    Post object.
     * @return void
     */
    public static function save_expiration_date( $post_id, $post ) {
        // Security checks
        if ( ! isset( $_POST['expiration_date_nonce'] ) ) {
            return;
        }

        if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['expiration_date_nonce'] ) ), 'save_expiration_date' ) ) {
            return;
        }

        if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
            return;
        }

        if ( ! current_user_can( 'edit_post', $post_id ) ) {
            return;
        }

        // Save expiration date
        if ( isset( $_POST['expiration_date'] ) && ! empty( $_POST['expiration_date'] ) ) {
            $expiration_date = strtotime( sanitize_text_field( wp_unslash( $_POST['expiration_date'] ) ) );
            update_post_meta( $post_id, '_expiration_date', $expiration_date );
        } else {
            delete_post_meta( $post_id, '_expiration_date' );
        }

        // Save expiration action
        if ( isset( $_POST['expiration_action'] ) ) {
            $allowed_actions = array( 'draft', 'trash', 'private', 'delete' );
            $action          = sanitize_text_field( wp_unslash( $_POST['expiration_action'] ) );

            if ( in_array( $action, $allowed_actions, true ) ) {
                update_post_meta( $post_id, '_expiration_action', $action );
            }
        }
    }
}

// Initialize content expiration
Content_Expiration_Manager::init();

Unscheduling Events

Properly unscheduling events is crucial for plugin deactivation, feature toggling, and preventing orphaned cron jobs that continue running indefinitely.

wp_unschedule_event()

Removes a specific scheduled event. You must provide the exact timestamp and hook name, along with any arguments that were used when scheduling.

PHP
/**
 * Unschedule a specific event
 *
 * @return void
 */
function unschedule_specific_backup() {
    $timestamp = wp_next_scheduled( 'perform_database_backup' );
    $hook      = 'perform_database_backup';
    $args      = array( 'backup_type' => 'full' );

    if ( $timestamp ) {
        wp_unschedule_event( $timestamp, $hook, $args );
    }
}

wp_clear_scheduled_hook()

Removes all scheduled occurrences of a hook, regardless of arguments or timestamp. This is the preferred method for cleanup during plugin deactivation.

PHP
/**
 * Clear all scheduled events for a hook
 *
 * @return void
 */
function clear_all_backup_events() {
    wp_clear_scheduled_hook( 'perform_database_backup' );
    wp_clear_scheduled_hook( 'cleanup_old_backups' );
}

wp_unschedule_hook()

Unschedules all events attached to a specific hook. Available since WordPress 4.9.0.

PHP
/**
 * Unschedule all events for a hook (WP 4.9+)
 *
 * @return void
 */
function unschedule_all_sync_events() {
    if ( function_exists( 'wp_unschedule_hook' ) ) {
        wp_unschedule_hook( 'sync_external_api_data' );
    }
}

Plugin Deactivation Hook

Always clear scheduled events when your plugin is deactivated to prevent orphaned cron jobs.

PHP
/**
 * Cleanup on plugin deactivation
 *
 * @return void
 */
function cleanup_plugin_cron_events() {
    // Clear all plugin-specific cron events
    $hooks = array(
        'perform_daily_database_backup',
        'cleanup_old_backups',
        'process_email_queue',
        'check_expired_content',
        'sync_external_api_data',
        'warm_site_cache',
        'generate_weekly_analytics_report',
    );

    foreach ( $hooks as $hook ) {
        wp_clear_scheduled_hook( $hook );
    }

    // Clear custom intervals (optional - they'll be re-added on reactivation)
    // Note: This doesn't actually remove the intervals from memory
    // but prevents confusion if plugin is reactivated
}
register_deactivation_hook( __FILE__, 'cleanup_plugin_cron_events' );

Debugging and Monitoring

Debugging WP-Cron can be challenging since events run in the background. These tools and techniques will help you monitor, troubleshoot, and verify cron execution.

Check if WP-Cron is Disabled

PHP
<?php
/**
 * Check if WP-Cron is disabled
 *
 * @return bool True if disabled, false otherwise.
 */
function is_wp_cron_disabled() {
    return defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON;
}

/**
 * Display admin notice if WP-Cron is disabled
 *
 * @return void
 */
function wp_cron_disabled_notice() {
    if ( is_wp_cron_disabled() ) {
        ?>
        <div class="notice notice-warning">
            <?php esc_html_e( 'WP-Cron is disabled. Scheduled events require system cron configuration.', 'textdomain' ); ?>

        </div>
        <?php
    }
}
add_action( 'admin_notices', 'wp_cron_disabled_notice' );

List All Scheduled Events

PHP
<?php
/**
 * Get all scheduled cron events
 *
 * @return array List of all scheduled events.
 */
function get_all_cron_events() {
    $crons  = _get_cron_array();
    $events = array();

    if ( empty( $crons ) ) {
        return $events;
    }

    foreach ( $crons as $timestamp => $cronhooks ) {
        foreach ( $cronhooks as $hook => $details ) {
            foreach ( $details as $key => $data ) {
                $events[] = array(
                    'hook'      => $hook,
                    'timestamp' => $timestamp,
                    'schedule'  => isset( $data['schedule'] ) ? $data['schedule'] : 'single',
                    'args'      => isset( $data['args'] ) ? $data['args'] : array(),
                    'time'      => gmdate( 'Y-m-d H:i:s', $timestamp ),
                    'in'        => human_time_diff( time(), $timestamp ),
                );
            }
        }
    }

    return $events;
}

/**
 * Display cron events in admin
 *
 * @return void
 */
function display_cron_events_admin_page() {
    if ( ! current_user_can( 'manage_options' ) ) {
        return;
    }

    $events = get_all_cron_events();
    ?>
    <div class="wrap">
        <h1><?php esc_html_e( 'Scheduled Cron Events', 'textdomain' ); ?></h1>
        <table class="wp-list-table widefat fixed striped">
            <thead>
                <tr>
                    <th><?php esc_html_e( 'Hook', 'textdomain' ); ?></th>
                    <th><?php esc_html_e( 'Next Run', 'textdomain' ); ?></th>
                    <th><?php esc_html_e( 'Schedule', 'textdomain' ); ?></th>
                    <th><?php esc_html_e( 'Recurrence', 'textdomain' ); ?></th>
                </tr>
            </thead>
            <tbody>
                <?php foreach ( $events as $event ) : ?>
                    <tr>
                        <td><code><?php echo esc_html( $event['hook'] ); ?></code></td>
                        <td><?php echo esc_html( $event['time'] . ' (' . $event['in'] . ')' ); ?></td>
                        <td><?php echo esc_html( $event['schedule'] ); ?></td>
                        <td><?php echo esc_html( $event['schedule'] !== 'single' ? 'Recurring' : 'One-time' ); ?></td>
                    </tr>
                <?php endforeach; ?>
            </tbody>
        </table>
    </div>
    <?php
}

Manually Trigger Cron Event

PHP
<?php
/**
 * Manually trigger a cron event for testing
 *
 * @param string $hook Hook name to execute.
 * @return void
 */
function manually_trigger_cron_event( $hook ) {
    // Security check
    if ( ! current_user_can( 'manage_options' ) ) {
        wp_die( esc_html__( 'Insufficient permissions', 'textdomain' ) );
    }

    // Verify nonce
    check_admin_referer( 'trigger_cron_event' );

    // Execute the hook
    do_action( sanitize_text_field( $hook ) );

    wp_safe_redirect( admin_url( 'tools.php?page=cron-manager&triggered=1' ) );
    exit;
}

/**
 * Add manual trigger button to admin
 *
 * @param string $hook_name Hook name.
 * @return void
 */
function render_manual_trigger_button( $hook_name ) {
    $url = wp_nonce_url(
        admin_url( 'admin-post.php?action=trigger_cron&hook=' . urlencode( $hook_name ) ),
        'trigger_cron_event'
    );
    ?>
    <a href="<?php echo esc_url( $url ); ?>" class="button button-secondary">
        <?php esc_html_e( 'Run Now', 'textdomain' ); ?>
    </a>
    <?php
}
add_action( 'admin_post_trigger_cron', 'manually_trigger_cron_event' );

Cron Execution Logger

PHP
/**
 * Cron Event Logger
 *
 * Logs all cron executions to custom database table.
 *
 * @package YourPlugin
 */
class Cron_Event_Logger {

    /**
     * Log table name
     *
     * @var string
     */
    private static $table_name = '';

    /**
     * Initialize logger
     *
     * @return void
     */
    public static function init() {
        global $wpdb;
        self::$table_name = $wpdb->prefix . 'cron_log';

        add_action( 'plugins_loaded', array( __CLASS__, 'create_table' ) );
        add_action( 'all', array( __CLASS__, 'log_cron_execution' ), 10, 0 );
    }

    /**
     * Create log table
     *
     * @return void
     */
    public static function create_table() {
        global $wpdb;

        $charset_collate = $wpdb->get_charset_collate();

        $sql = "CREATE TABLE IF NOT EXISTS " . self::$table_name . " (
            id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
            hook varchar(255) NOT NULL,
            execution_time datetime DEFAULT CURRENT_TIMESTAMP,
            duration float DEFAULT NULL,
            status varchar(20) DEFAULT 'success',
            memory_usage bigint(20) DEFAULT NULL,
            PRIMARY KEY  (id),
            KEY hook (hook),
            KEY execution_time (execution_time)
        ) $charset_collate;";

        require_once ABSPATH . 'wp-admin/includes/upgrade.php';
        dbDelta( $sql );
    }

    /**
     * Log cron execution
     *
     * @return void
     */
    public static function log_cron_execution() {
        $current_filter = current_filter();

        // Only log known cron hooks
        $cron_hooks = array(
            'perform_daily_database_backup',
            'process_email_queue',
            'check_expired_content',
            'sync_external_api_data',
        );

        if ( ! in_array( $current_filter, $cron_hooks, true ) ) {
            return;
        }

        $start_time   = microtime( true );
        $start_memory = memory_get_usage();

        // Let the event execute
        // We use a shutdown hook to record completion
        add_action(
            'shutdown',
            function() use ( $current_filter, $start_time, $start_memory ) {
                global $wpdb;

                $duration     = microtime( true ) - $start_time;
                $memory_usage = memory_get_usage() - $start_memory;

                $wpdb->insert(
                    Cron_Event_Logger::$table_name,
                    array(
                        'hook'          => $current_filter,
                        'duration'      => $duration,
                        'memory_usage'  => $memory_usage,
                        'status'        => 'success',
                    ),
                    array( '%s', '%f', '%d', '%s' )
                );
            }
        );
    }

    /**
     * Get execution stats for a hook
     *
     * @param string $hook Hook name.
     * @param int    $limit Number of recent executions to retrieve.
     * @return array Execution statistics.
     */
    public static function get_hook_stats( $hook, $limit = 10 ) {
        global $wpdb;

        return $wpdb->get_results(
            $wpdb->prepare(
                "SELECT * FROM " . self::$table_name . "
                WHERE hook = %s
                ORDER BY execution_time DESC
                LIMIT %d",
                $hook,
                $limit
            )
        );
    }
}

// Initialize logger
Cron_Event_Logger::init();

Replacing WP-Cron with System Cron

For high-traffic sites or sites requiring precise timing, replacing WP-Cron with system cron provides better performance and reliability. This involves disabling WP-Cron and configuring a server-level cron job to trigger wp-cron.php at regular intervals.

Step 1: Disable WP-Cron

Add this constant to your wp-config.php file before the “That’s all, stop editing!” line:

PHP
define( 'DISABLE_WP_CRON', true );

Step 2: Configure System Cron

Add a cron job to your server that runs every minute (or your preferred interval). Access your server’s crontab with crontab -e and add:

Shell
# Run WordPress cron every minute
* * * * * cd /path/to/wordpress && php wp-cron.php > /dev/null 2>&1

# Alternative using curl
* * * * * curl -s https://yourdomain.com/wp-cron.php > /dev/null 2>&1

# Alternative using wget
* * * * * wget -q -O - https://yourdomain.com/wp-cron.php > /dev/null 2>&1

Step 3: Verify System Cron is Working

PHP
<?php
/**
 * Admin notice to verify system cron configuration
 *
 * @return void
 */
function verify_system_cron_notice() {
    if ( ! current_user_can( 'manage_options' ) ) {
        return;
    }

    if ( ! defined( 'DISABLE_WP_CRON' ) || ! DISABLE_WP_CRON ) {
        return;
    }

    // Check if wp-cron.php was recently accessed
    $last_cron_run = get_transient( 'last_cron_run_time' );

    if ( ! $last_cron_run || ( time() - $last_cron_run ) > 120 ) {
        ?>
        <div class="notice notice-error">
            
                <strong><?php esc_html_e( 'System Cron Not Running!', 'textdomain' ); ?></strong>

                <?php esc_html_e( 'WP-Cron is disabled but system cron does not appear to be configured correctly.', 'textdomain' ); ?>
            

        </div>
        <?php
    }
}
add_action( 'admin_notices', 'verify_system_cron_notice' );

/**
 * Update last cron run time
 *
 * @return void
 */
function update_last_cron_run() {
    set_transient( 'last_cron_run_time', time(), 300 );
}
add_action( 'wp_cron', 'update_last_cron_run' );

Benefits of System Cron

  • Precise Timing: Events run at exact intervals regardless of traffic
  • Better Performance: No overhead from checking cron on every page load
  • Reliability: Works on low-traffic sites without delays
  • Resource Control: Dedicated execution window prevents overlap

Best Practices and Security

Always Check if Event is Already Scheduled

Before scheduling an event, always verify it’s not already scheduled to prevent duplicate executions.

PHP
// GOOD: Check before scheduling
if ( ! wp_next_scheduled( 'my_custom_event' ) ) {
    wp_schedule_event( time(), 'hourly', 'my_custom_event' );
}

// BAD: Schedule without checking (creates duplicates)
wp_schedule_event( time(), 'hourly', 'my_custom_event' );

Use Unique Hook Names

Prefix your hook names with your plugin/theme slug to avoid conflicts.

PHP
// GOOD: Unique, prefixed hook name
add_action( 'init', 'myplugin_schedule_events' );
function myplugin_schedule_events() {
    if ( ! wp_next_scheduled( 'myplugin_daily_cleanup' ) ) {
        wp_schedule_event( time(), 'daily', 'myplugin_daily_cleanup' );
    }
}

// BAD: Generic hook name (risk of conflicts)
wp_schedule_event( time(), 'daily', 'daily_cleanup' );

Implement Proper Error Handling

PHP
/**
 * Safe cron execution with error handling
 *
 * @return void
 */
function safe_cron_task_execution() {
    try {
        // Attempt risky operation
        $result = perform_risky_operation();

        if ( is_wp_error( $result ) ) {
            error_log( 'Cron Error: ' . $result->get_error_message() );
            return;
        }

        // Success logging
        update_option( 'last_successful_cron_run', current_time( 'timestamp' ) );

    } catch ( Exception $e ) {
        error_log( 'Cron Exception: ' . $e->getMessage() );
        
        // Optionally notify admin
        wp_mail(
            get_option( 'admin_email' ),
            'Cron Task Failed',
            'Error: ' . $e->getMessage()
        );
    }
}
add_action( 'my_cron_hook', 'safe_cron_task_execution' );

Limit Execution Time for Long Tasks

PHP
/**
 * Process large dataset in chunks
 *
 * @return void
 */
function process_large_dataset_in_chunks() {
    $batch_size    = 100;
    $max_execution = 30; // seconds
    $start_time    = time();

    $offset = get_option( 'dataset_processing_offset', 0 );

    while ( ( time() - $start_time ) < $max_execution ) {
        $items = get_items_to_process( $offset, $batch_size );

        if ( empty( $items ) ) {
            // Processing complete
            delete_option( 'dataset_processing_offset' );
            break;
        }

        foreach ( $items as $item ) {
            process_single_item( $item );
        }

        $offset += $batch_size;
        update_option( 'dataset_processing_offset', $offset );
    }
}

Secure Cron Endpoints

If exposing custom cron endpoints, always implement authentication.

PHP
/**
 * Secure custom cron endpoint
 *
 * @return void
 */
function secure_cron_endpoint() {
    // Verify secret key
    if ( ! isset( $_GET['key'] ) || $_GET['key'] !== get_option( 'cron_secret_key' ) ) {
        wp_die( esc_html__( 'Unauthorized', 'textdomain' ), 403 );
    }

    // Verify nonce for additional security
    if ( ! isset( $_GET['nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['nonce'] ) ), 'custom_cron_action' ) ) {
        wp_die( esc_html__( 'Invalid request', 'textdomain' ), 403 );
    }

    // Execute cron task
    do_action( 'custom_secure_cron_task' );

    wp_die( 'Success', 200 );
}
add_action( 'admin_post_nopriv_custom_cron', 'secure_cron_endpoint' );

Clean Up on Plugin Deactivation

Always unschedule events when deactivating plugins to prevent orphaned cron jobs.

PHP
/**
 * Plugin deactivation cleanup
 *
 * @return void
 */
function myplugin_deactivation_cleanup() {
    // Clear all plugin cron events
    wp_clear_scheduled_hook( 'myplugin_daily_cleanup' );
    wp_clear_scheduled_hook( 'myplugin_hourly_sync' );
    wp_clear_scheduled_hook( 'myplugin_weekly_report' );

    // Clear any stored cron-related options
    delete_option( 'myplugin_last_cron_run' );
    delete_option( 'myplugin_cron_errors' );
}
register_deactivation_hook( __FILE__, 'myplugin_deactivation_cleanup' );

Common Pitfalls to Avoid

1. Scheduling Events on Every Page Load

Problem: Scheduling events in actions like init without checking if already scheduled creates duplicate events.

PHP
// WRONG: Creates duplicate events on every page load
add_action( 'init', 'bad_scheduling_example' );
function bad_scheduling_example() {
    wp_schedule_event( time(), 'hourly', 'my_hourly_task' );
}

// CORRECT: Check before scheduling
add_action( 'init', 'good_scheduling_example' );
function good_scheduling_example() {
    if ( ! wp_next_scheduled( 'my_hourly_task' ) ) {
        wp_schedule_event( time(), 'hourly', 'my_hourly_task' );
    }
}

2. Not Passing Arguments Correctly

Problem: Arguments must match exactly when unscheduling or checking for scheduled events.

PHP
// Schedule with arguments
wp_schedule_event( time(), 'hourly', 'process_user_data', array( 'user_id' => 42 ) );

// WRONG: Won't find the event (missing arguments)
$next = wp_next_scheduled( 'process_user_data' );

// CORRECT: Include the same arguments
$next = wp_next_scheduled( 'process_user_data', array( 'user_id' => 42 ) );

3. Assuming Immediate Execution

Problem: WP-Cron doesn’t guarantee immediate execution. Events run when triggered by page loads after the scheduled time.

PHP
// Don't expect immediate execution
wp_schedule_single_event( time() + 60, 'send_notification' );
// The notification won't send in exactly 60 seconds
// It sends when someone visits the site after 60 seconds

// For immediate execution, use direct function calls
send_notification();

// Or for background processing, use wp_remote_post with blocking => false

4. Long-Running Tasks Blocking Execution

Problem: Cron tasks that take too long can cause timeouts or block subsequent events.

PHP
// WRONG: Processing millions of records at once
function process_all_users() {
    $users = get_users( array( 'number' => -1 ) ); // Gets ALL users
    foreach ( $users as $user ) {
        heavy_processing( $user );
    }
}

// CORRECT: Process in batches
function process_users_in_batches() {
    $batch_size = 50;
    $offset     = get_option( 'user_processing_offset', 0 );

    $users = get_users(
        array(
            'number' => $batch_size,
            'offset' => $offset,
        )
    );

    if ( empty( $users ) ) {
        delete_option( 'user_processing_offset' );
        return;
    }

    foreach ( $users as $user ) {
        heavy_processing( $user );
    }

    update_option( 'user_processing_offset', $offset + $batch_size );
}

5. Not Validating Data Before Processing

Problem: Cron tasks run without user interaction, so they must validate all data.

PHP
// WRONG: No validation
function process_scheduled_post( $post_id ) {
    $post = get_post( $post_id );
    wp_publish_post( $post_id ); // Could fail if post was deleted
}

// CORRECT: Validate before processing
function process_scheduled_post_safe( $post_id ) {
    $post = get_post( $post_id );

    // Validate post exists
    if ( ! $post ) {
        error_log( "Cron: Post {$post_id} not found" );
        return;
    }

    // Validate post is in expected status
    if ( 'draft' !== $post->post_status ) {
        error_log( "Cron: Post {$post_id} is not a draft" );
        return;
    }

    // Proceed with publishing
    wp_publish_post( $post_id );
}

6. Forgetting to Remove Transient Checks

Problem: Using transients to prevent duplicate execution but never clearing them.

PHP
// WRONG: Transient never cleared
function run_once_daily() {
    if ( get_transient( 'daily_task_running' ) ) {
        return;
    }

    set_transient( 'daily_task_running', true, HOUR_IN_SECONDS );
    perform_daily_task();
    // Transient not deleted - prevents future runs if task fails
}

// CORRECT: Always clear the lock
function run_once_daily_safe() {
    if ( get_transient( 'daily_task_running' ) ) {
        return;
    }

    set_transient( 'daily_task_running', true, HOUR_IN_SECONDS );

    try {
        perform_daily_task();
    } finally {
        delete_transient( 'daily_task_running' );
    }
}

Performance Optimization

Optimize Database Queries

PHP
/**
 * Optimized cron task with efficient queries
 *
 * @return void
 */
function optimized_cleanup_task() {
    global $wpdb;

    // Use direct SQL for bulk operations instead of get_posts()
    $wpdb->query(
        $wpdb->prepare(
            "DELETE FROM {$wpdb->posts}
            WHERE post_type = 'revision'
            AND post_date < %s LIMIT 1000", gmdate( 'Y-m-d', strtotime( '-30 days' ) ) ) ); // Clean up orphaned meta $wpdb->query(
        "DELETE pm FROM {$wpdb->postmeta} pm
        LEFT JOIN {$wpdb->posts} p ON p.ID = pm.post_id
        WHERE p.ID IS NULL"
    );

    // Clear object cache
    wp_cache_flush();
}

Use Transients for Rate Limiting

PHP
/**
 * Rate-limited API sync with transient
 *
 * @return void
 */
function rate_limited_api_sync() {
    // Check if we've synced recently
    if ( get_transient( 'api_sync_cooldown' ) ) {
        return; // Skip this run
    }

    // Perform sync
    $result = sync_with_external_api();

    if ( $result ) {
        // Set cooldown period (5 minutes)
        set_transient( 'api_sync_cooldown', true, 5 * MINUTE_IN_SECONDS );
    }
}

Implement Progress Tracking

PHP
/**
 * Track progress of long-running tasks
 *
 * @return void
 */
function tracked_batch_processor() {
    $total_items   = get_option( 'batch_total_items', 0 );
    $processed     = get_option( 'batch_processed_items', 0 );
    $batch_size    = 50;

    if ( $processed >= $total_items ) {
        // All done
        delete_option( 'batch_total_items' );
        delete_option( 'batch_processed_items' );
        return;
    }

    // Process next batch
    $items = get_items_batch( $processed, $batch_size );

    foreach ( $items as $item ) {
        process_item( $item );
        $processed++;
    }

    update_option( 'batch_processed_items', $processed );

    // Calculate progress
    $progress = ( $processed / $total_items ) * 100;
    update_option( 'batch_progress', round( $progress, 2 ) );
}

Memory Management

PHP
/**
 * Memory-efficient large dataset processing
 *
 * @return void
 */
function memory_efficient_processing() {
    global $wpdb;

    $batch_size = 100;
    $offset     = 0;

    do {
        // Process in small chunks
        $posts = $wpdb->get_results(
            $wpdb->prepare(
                "SELECT ID, post_title FROM {$wpdb->posts}
                WHERE post_status = 'publish'
                AND post_type = 'post'
                LIMIT %d OFFSET %d",
                $batch_size,
                $offset
            )
        );

        if ( empty( $posts ) ) {
            break;
        }

        foreach ( $posts as $post ) {
            process_post( $post );
        }

        // Free memory
        unset( $posts );
        wp_cache_flush();

        $offset += $batch_size;

        // Memory threshold check
        if ( memory_get_usage() > ( 50 * 1024 * 1024 ) ) { // 50MB
            error_log( 'Cron: Memory threshold reached, stopping batch' );
            break;
        }

    } while ( true );
}

Conclusion

WordPress WP-Cron is a powerful scheduling system that enables automated tasks without requiring server-level access. By following the best practices outlined in this guide, implementing proper error handling, and understanding the limitations of the system, you can build reliable, performant, and secure automated workflows for your WordPress site.

Remember these key takeaways:

  • Always check if events are already scheduled before creating new ones
  • Use unique, prefixed hook names to avoid conflicts
  • Implement proper error handling and logging
  • Clean up scheduled events on plugin deactivation
  • Consider system cron for high-traffic sites requiring precise timing
  • Process large datasets in batches to avoid timeouts
  • Validate all data before processing in cron tasks
  • Monitor and debug cron execution regularly

With these techniques and examples, you’re equipped to leverage WP-Cron effectively for any automation requirement in your WordPress projects.

← WordPress Zero-Day Vulnerabilities: How to Detect and Monitor Threats with 0 Day Analytics Error Log Module — User Guide →
Share this page
Back to top