To run a task on a schedule in WordPress, you register a recurring event with wp_schedule_event(), hook a callback onto the event name, and let WP-Cron fire it. The whole core of it is three calls:
add_action( 'init', 'te_schedule_daily_event' );
function te_schedule_daily_event() {
if ( ! wp_next_scheduled( 'te_daily_event' ) ) {
wp_schedule_event( time(), 'daily', 'te_daily_event' );
}
}
add_action( 'te_daily_event', 'te_run_daily_task' );
function te_run_daily_task() {
// The work you want to happen once a day.
update_option( 'te_last_run', current_time( 'mysql' ) );
}wp_schedule_event( time(), 'daily', 'te_daily_event' ) says "starting now, run the te_daily_event hook once a day." The wp_next_scheduled() guard makes sure you only ever register it once. The add_action( 'te_daily_event', ... ) line is what actually does the work when the event fires. That is the entire pattern; the rest of this article is about the parts that bite you.
The signature, and the built-in intervals
wp_schedule_event() takes a timestamp, a recurrence name, the hook to fire, and optional args:
wp_schedule_event( int $timestamp, string $recurrence, string $hook, array $args = array() );The $timestamp is the first time the event should run (a Unix timestamp in UTC; time() means "now"). The $recurrence is a named schedule, and WordPress ships four out of the box:
| Recurrence | Interval |
|---|---|
hourly | every 3600 seconds |
twicedaily | every 12 hours |
daily | every 24 hours |
weekly | every 7 days |
weekly was added in WordPress 5.4 (March 2020). On older cores only hourly, twicedaily, and daily exist, which is exactly why the next section matters: if the interval you need is not one of these, you add your own.
Adding a custom interval with the cron_schedules filter
Say you want a task to run every fifteen minutes. There is no fifteen_minutes recurrence, so you register one through the cron_schedules filter. The filter hands you the array of existing schedules and expects you to return it with your addition merged in:
add_filter( 'cron_schedules', 'te_add_cron_interval' );
function te_add_cron_interval( $schedules ) {
$schedules['te_fifteen_minutes'] = array(
'interval' => 15 * MINUTE_IN_SECONDS,
'display' => __( 'Every 15 Minutes', 'te-scheduled-task' ),
);
return $schedules;
}Each entry needs an interval in seconds and a human-readable display string. MINUTE_IN_SECONDS is a WordPress core constant (it equals 60), and there are siblings for HOUR_IN_SECONDS, DAY_IN_SECONDS, and WEEK_IN_SECONDS so you do not hand-multiply.
Two things people get wrong here. First: always add to the passed array and return the whole thing. If you build a fresh array and return only your own schedule, you wipe out every interval that core and other plugins registered, and their scheduled tasks quietly stop. Second: register the filter before you call wp_schedule_event() with that interval name. If the schedule does not exist at scheduling time, the event will not run on the cadence you expect.
With the interval defined, schedule against its key:
if ( ! wp_next_scheduled( 'te_quarter_hour_event' ) ) {
wp_schedule_event( time(), 'te_fifteen_minutes', 'te_quarter_hour_event' );
}The wp_next_scheduled() guard, and cleaning up on deactivation
The single most common WP-Cron bug is scheduling the same event over and over. If you call wp_schedule_event() on a hook like init with no guard, you register a new recurring event on every page load. Within minutes you have hundreds of duplicate entries in the cron array, all firing the same callback, and a wp_options row (cron) that balloons.
wp_next_scheduled( 'te_daily_event' ) returns the Unix timestamp of the next scheduled run, or false if the event is not scheduled at all. Wrapping the schedule call in if ( ! wp_next_scheduled( ... ) ) means you only ever register it once, no matter how many times the hook runs. Never schedule a recurring event without this guard.
The flip side is cleanup. A scheduled event lives in the database, not in your code, so deactivating or deleting your plugin does not remove it. The orphaned event keeps firing a hook with no callback (harmless but messy) until something clears it. So you register the schedule on activation and tear it down on deactivation:
register_activation_hook( __FILE__, 'te_activate_scheduled_task' );
function te_activate_scheduled_task() {
if ( ! wp_next_scheduled( 'te_daily_event' ) ) {
wp_schedule_event( time(), 'daily', 'te_daily_event' );
}
}
register_deactivation_hook( __FILE__, 'te_deactivate_scheduled_task' );
function te_deactivate_scheduled_task() {
wp_clear_scheduled_hook( 'te_daily_event' );
}wp_clear_scheduled_hook() unschedules every occurrence of that hook. Scheduling on activation (rather than init) is cleaner when your code ships as a plugin, because activation runs exactly once. The init approach is the fallback for mu-plugins and theme code, where there is no activation hook to latch onto; there the wp_next_scheduled() guard is doing all the work.
The big gotcha: WP-Cron is not a real cron
Here is the part the documentation buries and the part that has cost me real debugging time. WP-Cron does not run on a clock. It is not a daemon. There is no system process watching the time and firing your tasks at 3:00am.
Instead, on every front-end page load, WordPress checks the cron array, sees whether any scheduled event is now due, and if so spawns a non-blocking request to wp-cron.php to run it. The phrasing in the official reference is honest about this: "The action will trigger when someone visits your WordPress site if the scheduled time has passed."
That request-driven design has two failure modes:
- Low-traffic site: tasks run late, or never. If nobody visits your site between midnight and noon, your "daily at midnight" task does not run at midnight. It runs whenever the next visitor shows up, which might be hours late. A brochure site with a handful of daily visitors can have a
dailytask that effectively fires once every few days. - High-traffic site: the check runs too much. Every page load does the "is anything due?" check and, when something is due, fires off that extra loopback request. On a busy site this is a steady stream of
wp-cron.phphits, and a slow scheduled task can pile up under concurrent traffic.
So WP-Cron's scheduling is approximate by design. For "clear an expired transient sometime today" that is completely fine. For "send the invoice batch at exactly 02:00" or "hit a payment API on a strict cadence," it is not.
The production fix: turn off WP-Cron and use the real system cron
The standard fix is to stop WordPress from triggering cron on page loads and instead drive wp-cron.php from the operating system's own scheduler on a fixed clock. First, disable the request-driven trigger in wp-config.php:
define( 'DISABLE_WP_CRON', true );This does not disable the scheduling system. Your events still live in the cron array and still fire when wp-cron.php runs; you have only switched off the "spawn it from a visitor's page load" mechanism. Now you call wp-cron.php yourself from the real crontab. Edit it with crontab -e and add a line that runs every five minutes:
*/5 * * * * curl -s "https://example.com/wp-cron.php?doing_wp_cron" >/dev/null 2>&1The ?doing_wp_cron query string tells WordPress this is a real cron trigger. Every five minutes the OS hits wp-cron.php, WordPress checks what is due, and runs it. Now your "daily at midnight" task actually fires near midnight regardless of traffic, and there is no per-pageview overhead.
If you have WP-CLI on the server, the cleaner trigger runs the due events directly in PHP instead of going back through HTTP, which sidesteps loopback and TLS quirks:
*/5 * * * * cd /var/www/example.com && wp cron event run --due-now >/dev/null 2>&1I prefer the WP-CLI form on any host where I control the crontab. It avoids the self-request entirely, it logs cleanly, and it does not depend on the site being reachable over its own public URL (which loopback requests sometimes are not, behind certain proxies or split-horizon DNS).
Pick a crontab cadence that is finer than your shortest WordPress schedule. If your tightest WP event is every fifteen minutes, a five-minute crontab is plenty; events still only fire on their own interval, the crontab just provides frequent enough opportunities to notice they are due.
Verify it with WP-CLI
Do not trust that an event is scheduled, check it. wp cron event list prints every registered event, its hook, its next run time, and its recurrence:
wp cron event list+----------------------+---------------------+---------------------+--------------------+
| hook | next_run_gmt | next_run_relative | recurrence |
+----------------------+---------------------+---------------------+--------------------+
| te_daily_event | 2026-05-31 00:00:00 | 7 hours 12 minutes | 1 day |
| wp_version_check | 2026-05-30 18:14:03 | 1 hour 26 minutes | 12 hours |
| wp_update_plugins | 2026-05-30 18:14:03 | 1 hour 26 minutes | 12 hours |
+----------------------+---------------------+---------------------+--------------------+
This is also how you spot the duplicate-scheduling bug: if te_daily_event shows up a dozen times, your guard is missing or your filter ran too late. To force an event to run right now for testing, wp cron event run te_daily_event. To see the registered intervals (including your custom one), wp cron schedule list.
Long or heavy tasks: do not run them inline
One last warning. The callback on a scheduled event runs inside a single PHP request, with that request's max_execution_time and memory limit. A task that loops over ten thousand posts, or makes a slow external API call per row, will time out or run out of memory partway through and leave the job half-done with no easy retry.
For anything heavy or batched, do not do the work inline in one cron callback. Offload it to Action Scheduler, the background-processing library that ships inside WooCommerce and is available standalone. It queues work as individual actions, processes them in time-boxed batches, retries failures, and gives you an admin UI to watch the queue. WP-Cron's own scheduling is the right tool for "run this small thing on a cadence"; Action Scheduler is the right tool for "process this large amount of work reliably." On a busy store the two often coexist, which is one of the levers I cover in optimizing a slow WooCommerce site.
A complete, ready-to-drop-in plugin
Putting the pieces together, here is the whole thing as a small plugin. Activate it and wp cron event list shows te_daily_event; deactivate it and the event is gone:
<?php
/**
* Plugin Name: TE Scheduled Task
* Plugin URI: https://techearl.com/wordpress-wp-cron-schedule-recurring-task
* Description: Registers a recurring WP-Cron event with a custom interval, guarded against duplicate scheduling, and cleans up on deactivation.
* Version: 1.0.0
* Author: Ishan Karunaratne
* Author URI: https://techearl.com
* License: GPL-2.0-or-later
* Text Domain: te-scheduled-task
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// 1. Register a custom interval (delete this if a built-in one fits).
add_filter( 'cron_schedules', 'te_add_cron_interval' );
function te_add_cron_interval( $schedules ) {
$schedules['te_fifteen_minutes'] = array(
'interval' => 15 * MINUTE_IN_SECONDS,
'display' => __( 'Every 15 Minutes', 'te-scheduled-task' ),
);
return $schedules;
}
// 2. Schedule the event on activation, only if it is not already scheduled.
register_activation_hook( __FILE__, 'te_activate_scheduled_task' );
function te_activate_scheduled_task() {
if ( ! wp_next_scheduled( 'te_daily_event' ) ) {
wp_schedule_event( time(), 'daily', 'te_daily_event' );
}
}
// 3. Clear the event on deactivation so it does not orphan.
register_deactivation_hook( __FILE__, 'te_deactivate_scheduled_task' );
function te_deactivate_scheduled_task() {
wp_clear_scheduled_hook( 'te_daily_event' );
}
// 4. The work that runs when the event fires.
add_action( 'te_daily_event', 'te_run_daily_task' );
function te_run_daily_task() {
update_option( 'te_last_run', current_time( 'mysql' ) );
}Keep the callback lean, guard the schedule, clear it on deactivation, and on any site where timing matters, drive it from the system crontab rather than trusting page traffic to do it for you.
See also
- AI-assisted WP-CLI workflows: more of what WP-CLI buys you on the command line, including inspecting and driving cron from the terminal
- Clean up wp_head in WordPress: another "remove the default machinery you do not need" pattern, this time for front-end head output rather than scheduled events
- Pull GA4 data into WordPress with a service account: a real-world use for a recurring task, fetching analytics on a schedule and caching it
- How to optimize WooCommerce: where Action Scheduler and background processing fit into keeping a busy store fast
Sources
Authoritative references this article was fact-checked against.
- wp_schedule_event(): WordPress Developer Referencedeveloper.wordpress.org
- cron_schedules filter: WordPress Developer Referencedeveloper.wordpress.org
- Hooking WP-Cron Into the System Task Scheduler: Plugin Handbookdeveloper.wordpress.org





