Plugins often need to do work that is not tied to a visitor: send a daily digest, sync data with an external API, clean up expired records. WordPress provides a scheduler called WP-Cron for this. It is simple to use, but it does not work like a real cron daemon, and understanding the difference is what makes scheduled tasks reliable.
How WP-Cron works
WP-Cron is not a background process. Scheduled events are stored in a single option, cron, as a list of hooks with a timestamp, an optional recurrence and arguments.
On page loads, WordPress checks whether any event is due. If one is, it sends a non-blocking HTTP request to wp-cron.php on the same site (a "loopback" request). That separate request runs the due events by firing their hooks with do_action().
Consequences:
- No traffic, no cron. On a quiet site, an event scheduled for 03:00 runs when the next visitor arrives, which might be 08:30.
- Timing is approximate. "Hourly" means "at the first request after an hour has passed".
- Page caches hide traffic. If a full-page cache serves most requests, PHP rarely runs and cron rarely triggers.
- Loopbacks can fail. Firewalls, basic auth on staging or DNS issues can stop the server from requesting itself. Site Health reports loopback failures.
For anything that matters, run WP-Cron from the system scheduler, as shown later.
Scheduling a recurring event
A scheduled event is just a hook name. You attach your callback to that hook, then tell WP-Cron when to fire it. This plugin syncs data daily:
<?php
/**
* Plugin Name: My Plugin Daily Sync
*/
defined( 'ABSPATH' ) || exit;
// 1. The callback that does the work.
add_action( 'myplugin_daily_sync', 'myplugin_run_daily_sync' );
function myplugin_run_daily_sync(): void {
$response = wp_remote_get( 'https://api.example.com/rates', array( 'timeout' => 15 ) );
if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
error_log( 'myplugin: daily sync failed' );
return;
}
$data = json_decode( wp_remote_retrieve_body( $response ), true );
if ( is_array( $data ) ) {
update_option( 'myplugin_rates', $data, false );
}
}
// 2. Schedule it when the plugin is activated.
register_activation_hook( __FILE__, 'myplugin_schedule_sync' );
function myplugin_schedule_sync(): void {
if ( ! wp_next_scheduled( 'myplugin_daily_sync' ) ) {
wp_schedule_event( time(), 'daily', 'myplugin_daily_sync' );
}
}
// 3. Remove it when the plugin is deactivated.
register_deactivation_hook( __FILE__, 'myplugin_unschedule_sync' );
function myplugin_unschedule_sync(): void {
wp_clear_scheduled_hook( 'myplugin_daily_sync' );
}
Key points:
wp_schedule_event( $timestamp, $recurrence, $hook, $args )takes a Unix timestamp in UTC for the first run.time()means "as soon as possible".- Always check
wp_next_scheduled()first. Scheduling the same event again creates a duplicate. - Always clear your events on deactivation, or WordPress keeps firing a hook nobody listens to.
- Core schedules are
hourly,twicedaily,dailyandweekly.
Activation hooks do not run when a plugin is updated or deployed by copying files. Many plugins also check on init and schedule the event if it is missing. wp_next_scheduled() is cheap because the cron option is autoloaded.
Running at a specific time of day
To run near 02:00 site time rather than "24 hours after activation", calculate the first timestamp with the site's time zone:
$next = new DateTimeImmutable( 'tomorrow 02:00', wp_timezone() );
wp_schedule_event( $next->getTimestamp(), 'daily', 'myplugin_daily_sync' );
Custom intervals
Add your own recurrence with the cron_schedules filter. Register it at file load so it exists before you schedule anything:
add_filter( 'cron_schedules', 'myplugin_cron_schedules' );
function myplugin_cron_schedules( array $schedules ): array {
$schedules['myplugin_fifteen_minutes'] = array(
'interval' => 15 * MINUTE_IN_SECONDS,
'display' => __( 'Every 15 minutes', 'myplugin' ),
);
return $schedules;
}
Then use 'myplugin_fifteen_minutes' as the recurrence. If the filter is ever removed while events still use it, those events stop rescheduling, so keep it registered as long as the event exists.
One-off events and arguments
wp_schedule_single_event() runs a hook once. It is useful for deferring slow work out of a user's request, such as sending notifications after a post is published:
add_action( 'transition_post_status', 'myplugin_queue_notification', 10, 3 );
function myplugin_queue_notification( string $new_status, string $old_status, WP_Post $post ): void {
if ( 'publish' !== $new_status || 'publish' === $old_status || 'post' !== $post->post_type ) {
return;
}
wp_schedule_single_event( time(), 'myplugin_send_notification', array( $post->ID ) );
}
add_action( 'myplugin_send_notification', 'myplugin_send_notification', 10, 1 );
function myplugin_send_notification( int $post_id ): void {
// Send emails or call a webhook for $post_id.
}
Arguments are part of the event's identity. wp_next_scheduled( 'myplugin_send_notification', array( 42 ) ) only finds the event with exactly those arguments, and the callback must accept as many arguments as you pass (set the fourth argument of add_action() to match).
Running WP-Cron from the system scheduler
On production, replace the page-load trigger with a real cron job that runs every few minutes. First stop WordPress triggering cron on page loads, in wp-config.php:
define( 'DISABLE_WP_CRON', true );
This constant does not disable scheduled events. It only stops the automatic trigger. Something else must now run them. With WP-CLI, add a crontab entry for the user who owns the site:
*/5 * * * * cd /var/www/example.com && wp cron event run --due-now --quiet
Without WP-CLI, request wp-cron.php directly:
*/5 * * * * curl -s "https://example.com/wp-cron.php?doing_wp_cron" > /dev/null 2>&1
The WP-CLI version runs in a PHP CLI process, which usually has no web server timeout. On Multisite, each site has its own cron queue, so run due events for every site:
*/5 * * * * cd /var/www/example.com && wp site list --field=url | xargs -I {} wp cron event run --due-now --url={} --quiet
ALTERNATE_WP_CRON is a fallback for servers where loopback requests fail. It runs cron through a redirect on a visitor's request. Prefer a system cron if you have one.
Inspecting and testing with WP-CLI
wp cron event list
wp cron event list --fields=hook,next_run_relative,recurrence
wp cron event run myplugin_daily_sync
wp cron event delete myplugin_daily_sync
wp cron schedule list
wp cron test
wp cron event run <hook> runs an event immediately, which is the easiest way to test a callback. wp cron test checks whether the site can spawn WP-Cron through a loopback request.
Long-running jobs
A cron request is still a PHP request with memory and time limits. Processing 50,000 records in one callback will eventually fail halfway, and you will not know where it stopped. Split the job into batches, and make each batch schedule the next.
add_action( 'myplugin_reindex_batch', 'myplugin_reindex_batch', 10, 1 );
function myplugin_start_reindex(): void {
if ( ! wp_next_scheduled( 'myplugin_reindex_batch', array( 0 ) ) ) {
wp_schedule_single_event( time(), 'myplugin_reindex_batch', array( 0 ) );
}
}
function myplugin_reindex_batch( int $last_id ): void {
// Lock so two runners never process the same batch.
if ( ! add_option( 'myplugin_reindex_lock', time(), '', false ) ) {
return;
}
global $wpdb;
$ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT ID FROM {$wpdb->posts}
WHERE post_type = %s AND post_status = 'publish' AND ID > %d
ORDER BY ID ASC LIMIT %d",
'myplugin_event',
$last_id,
100
)
);
foreach ( $ids as $id ) {
myplugin_reindex_post( (int) $id ); // Your own per-item work.
}
delete_option( 'myplugin_reindex_lock' );
if ( count( $ids ) === 100 ) {
$next = (int) end( $ids );
wp_schedule_single_event( time() + 30, 'myplugin_reindex_batch', array( $next ) );
} else {
update_option( 'myplugin_reindex_done', time(), false );
}
}
Why it is built this way:
- Keyset pagination (
ID > %d) stays fast and stable, unlikeOFFSET, even when rows change during the job. - The last processed ID is the argument, so the job can resume after a failure.
add_option()as a lock is atomic: it fails if the option already exists. A crashed batch leaves the lock behind, so a real implementation also treats an old lock timestamp as stale.- Each batch is small enough to finish well within time and memory limits.
For heavy or high-volume queues, a dedicated job queue library such as Action Scheduler (free, used by many large plugins) adds retries, logging and an admin screen on top of the same idea. For very large workloads, a separate worker process or queue service is the next step; the scale lesson discusses that.
Common mistakes
- Scheduling on every page load without checking
wp_next_scheduled(), creating thousands of duplicate events. - Forgetting to clear events on deactivation.
- Relying on exact timing on a low-traffic or fully cached site without a system cron.
- Doing slow remote calls in a cron job without a timeout.
- Assuming a logged-in user: cron runs with no current user, so
current_user_can()returns false.
Summary
- WP-Cron stores events in an option and runs due ones through a loopback request triggered by page loads.
- Schedule with
wp_schedule_event()after checkingwp_next_scheduled(), and clear withwp_clear_scheduled_hook()on deactivation. - Add intervals with the
cron_schedulesfilter; usewp_schedule_single_event()to defer work. - In production, set
DISABLE_WP_CRONand runwp cron event run --due-nowfrom the system crontab. - Inspect and test with
wp cron event list,wp cron event runandwp cron test. - Split long jobs into small, resumable batches with a lock, or use a job queue library for heavy workloads.