TechEarl

Schedule a Recurring Task with WP-Cron (and When to Use a Real Cron)

How to schedule a recurring task in WordPress with wp_schedule_event(), add a custom interval via the cron_schedules filter, guard it with wp_next_scheduled(), and when to ditch WP-Cron for a real system cron.

Ishan Karunaratne⏱️ 11 min readUpdated
Share thisCopied
How to schedule a recurring task in WordPress: register the event with wp_schedule_event() guarded by wp_next_scheduled(), add a custom interval through the cron_schedules filter, and trigger it from a real system crontab when WP-Cron's request-driven model is not reliable enough.

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:

php
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:

php
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:

RecurrenceInterval
hourlyevery 3600 seconds
twicedailyevery 12 hours
dailyevery 24 hours
weeklyevery 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:

php
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:

php
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:

php
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 daily task 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.php hits, 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:

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:

bash
*/5 * * * * curl -s "https://example.com/wp-cron.php?doing_wp_cron" >/dev/null 2>&1

The ?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:

bash
*/5 * * * * cd /var/www/example.com && wp cron event run --due-now >/dev/null 2>&1

I 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:

bash
wp cron event list
code
+----------------------+---------------------+---------------------+--------------------+
| 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
<?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

Sources

Authoritative references this article was fact-checked against.

TagsWordPressWP-CronCronDevOpsWP-CLIPHP

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts

How to Count Matches with grep -c (and the Line-vs-Occurrence Trap)

grep -c counts matching LINES, not occurrences. A line with three hits still counts as 1. The fix is grep -o piped into wc -l, which puts every match on its own line first. Per-file counts, filtering out the :0 noise, counting non-matching lines, and the BSD vs GNU differences.