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.