Writing

wp_schedule_event mistakes: why a WordPress cron job runs twice, or never

· Kristoff Bertram

You had a plugin written that does something every hour, and now it runs twice, or forty times, or never, or runs and quietly changes nothing. Before blaming the server, read the scheduling code. Four mistakes account for most of it, and each has a short fix.

WordPress stores scheduled events in the database as a list of hooks and times. Your code adds to that list with wp_schedule_event() and answers a due event with add_action(). The list is where these bugs show up, so look at it first (see the last section). Nothing below is specific to AI-written code, but generated code makes these four mistakes often, because each one looks right when you read it.

1. Scheduling on every page load

The most common version hangs the call on init, or leaves it in the main plugin file:

add_action( 'init', function () {
	wp_schedule_event( time(), 'hourly', 'myplugin_sync' );
} );

It works the first time. It also works the second time, and the third: every request adds another copy of the event, so the job runs as many times as the site has been loaded. The fix is to ask first with wp_next_scheduled(), and to schedule once, on activation. Then clean up on deactivation, or the event stays in the list and keeps firing a hook nothing answers:

register_activation_hook( __FILE__, function () {
	if ( ! wp_next_scheduled( 'myplugin_sync' ) ) {
		wp_schedule_event( time() + HOUR_IN_SECONDS, 'hourly', 'myplugin_sync' );
	}
} );

register_deactivation_hook( __FILE__, function () {
	wp_clear_scheduled_hook( 'myplugin_sync' );
} );

add_action( 'myplugin_sync', 'myplugin_run_sync' );

If duplicates already exist, wp_clear_scheduled_hook() removes every one of them, and the activation code puts back a single event.

2. A custom interval that was never registered

WordPress knows hourly, twicedaily, daily and weekly. Ask for every_fifteen_minutes without teaching WordPress what that means, and wp_schedule_event() reads the list of known schedules, does not find the name, and returns false. Nothing is scheduled, and no error is shown. Register the interval with the cron_schedules filter, and register it on every request, not only on activation, because WordPress reads the list each time it schedules or reschedules:

add_filter( 'cron_schedules', function ( $schedules ) {
	$schedules['every_fifteen_minutes'] = array(
		'interval' => 15 * MINUTE_IN_SECONDS,
		'display'  => 'Every 15 minutes',
	);
	return $schedules;
} );

register_activation_hook( __FILE__, function () {
	if ( ! wp_next_scheduled( 'myplugin_sync' ) ) {
		wp_schedule_event( time(), 'every_fifteen_minutes', 'myplugin_sync' );
	}
} );

3. One giant event

A job that imports ten thousand rows in one callback runs into the PHP time limit or the memory limit, stops halfway, and starts from the top next time. Split it: do a fixed batch, remember where you stopped, and schedule the next batch as a single event a minute out.

add_action( 'myplugin_import_batch', function () {
	$offset = (int) get_option( 'myplugin_import_offset', 0 );
	$rows   = myplugin_fetch_rows( $offset, 50 );

	foreach ( $rows as $row ) {
		myplugin_import_row( $row );
	}

	if ( count( $rows ) === 50 ) {
		update_option( 'myplugin_import_offset', $offset + 50, false );
		wp_schedule_single_event( time() + 60, 'myplugin_import_batch' );
	} else {
		delete_option( 'myplugin_import_offset' );
	}
} );

A short batch means the list is finished, so the chain ends. Each run is small enough to finish, and a failure costs one batch rather than the whole job.

4. Assuming a logged-in user

A cron callback runs in a request started by WordPress itself, not by a person. Nobody is logged in, so current_user_can() is always false:

add_action( 'myplugin_sync', function () {
	// Broken: nobody is logged in during cron, so this is always false.
	if ( ! current_user_can( 'manage_options' ) ) {
		return;
	}
	myplugin_sync_everything();
} );

This is the “runs and silently does nothing” case: the event fires on time, the callback returns on its first line, and the log is empty. Permission checks belong in the code a person triggers, such as a button or a REST route. The cron callback is already trusted, because only your own code can schedule it. Drop the check. If the work needs a user, say whose, with wp_set_current_user() and an ID you look up, rather than hoping for one.

How to see what is scheduled

With a shell and WP-CLI, three commands cover it. wp cron event list shows every event, its next run and its recurrence, so duplicates are easy to spot. wp cron test checks that WordPress can start its own cron request. wp cron event run myplugin_sync runs your hook now, which is the quickest way to find out whether the callback itself works.

Without a shell, the same list is in the database, in the cron row of the options table, where it is stored as serialised data; add error_log() calls to the callback and read the PHP error log to see when it actually runs.

When the code is right and it still runs late

If the event is scheduled once, the interval is registered and the callback works, but the job still fires late or not at all, the problem has moved from your code to WP-Cron itself, which only runs when somebody loads a page. That has its own page: WordPress cron not running explains why, and how to replace it with a real server cron.