
How to Set Up Automatic Cron Jobs in WordPress?
WP-Cron isn't a real cron job; it's a pseudo-cron that only checks for due tasks when a visitor loads a page, which causes real problems on quiet sites. Every time someone requests a page on your site, WordPress checks whether any scheduled tasks are due and, if so, runs them right then, in the middle of that visitor's page load. On a busy site this works fine, since pages load constantly and scheduled tasks fire close enough to on time. On a low-traffic site, or one sitting behind aggressive caching that serves pages without ever hitting PHP, tasks scheduled for 3 a.m. might not actually run until the next visitor happens to show up at 11 a.m.
This guide covers both sides of WordPress's cron system: how to schedule your own recurring and one-time tasks using the real API, and how to fix the underlying timing problem by replacing WP-Cron's page-load trigger with an actual system cron job.
How WP-Cron Works By Default
Every WordPress installation includes wp-cron.php, and on every page request, a small piece of code checks whether any scheduled task is due and, if so, triggers a request to that file in the background. This design means WordPress never needs real server-level cron access to schedule things like checking for plugin updates, publishing scheduled posts, or clearing out expired transients. It also means the entire system depends on traffic to function: no page views, no cron checks, no tasks running, regardless of how precisely you scheduled them.
This matters more than it sounds like it should. A daily backup task scheduled for 2 a.m. isn't actually guaranteed to run at 2 a.m., only "the next time someone loads a page after 2 a.m." If that's 2:03 a.m., fine. If your site gets its next visitor at 9 a.m. because it's a niche B2B tool nobody browses overnight, that backup runs six hours late, every single day.
Scheduling a Recurring Task
WordPress's cron API is built around three functions used together: wp_next_scheduled() to check whether a task is already scheduled, wp_schedule_event() to schedule it, and add_action() to pair the event name with the function that should actually run.
// The function that runs on the scheduled event
add_action( 'tw_daily_cleanup_event', 'tw_run_daily_cleanup' );
function tw_run_daily_cleanup() {
global $wpdb;
// Remove expired custom transients directly at the database level
$wpdb->query(
"DELETE FROM {$wpdb->options}
WHERE option_name LIKE '\_transient\_timeout\_tw\_%'
AND option_value < UNIX_TIMESTAMP()"
);
}
// Schedule it once, on plugin activation
register_activation_hook( __FILE__, function () {
if ( ! wp_next_scheduled( 'tw_daily_cleanup_event' ) ) {
wp_schedule_event( time(), 'daily', 'tw_daily_cleanup_event' );
}
} );
// Clean up the schedule on deactivation
register_deactivation_hook( __FILE__, function () {
$timestamp = wp_next_scheduled( 'tw_daily_cleanup_event' );
if ( $timestamp ) {
wp_unschedule_event( $timestamp, 'tw_daily_cleanup_event' );
}
} );
A few things about this pattern are easy to get wrong if you skip them:
register_activation_hook()andregister_deactivation_hook()are only available inside a plugin's main file (they rely on__FILE__pointing at that file). If you're working directly in a theme'sfunctions.phpinstead, hook your scheduling call intoafter_setup_themeand rely entirely on thewp_next_scheduled()guard to prevent duplicate scheduling on every page load.wp_next_scheduled( 'tw_daily_cleanup_event' )is the guard that stops you from accidentally scheduling the same recurring event dozens of times over. Without it, every activation (or every page load, if scheduling code runs outside an activation hook) adds another duplicate schedule, and your task starts running multiple times per interval instead of once.- The event name (
tw_daily_cleanup_event) is just a string, not a registered "type" WordPress validates against anything, so it needs to be unique enough not to collide with an event name from another plugin. Prefixing it, the same way you'd prefix a function or option name, avoids that. - Always pair the unschedule call in a deactivation hook. A scheduled event that's never unscheduled keeps firing forever, including after you've deactivated the plugin that was supposed to own it, calling a function that plugin's code may no longer fully support.
Adding a Custom Cron Interval
WordPress ships with only three built-in intervals: hourly, twicedaily, and daily. If your task needs to run more frequently, say every 15 minutes, you register a new interval using the cron_schedules filter before referencing it in wp_schedule_event():
add_filter( 'cron_schedules', function ( $schedules ) {
$schedules['every_fifteen_minutes'] = [
'interval' => 15 * MINUTE_IN_SECONDS,
'display' => __( 'Every 15 Minutes' ),
];
return $schedules;
} );
add_action( 'tw_check_external_feed_event', 'tw_check_external_feed' );
function tw_check_external_feed() {
// Poll a third-party API or RSS feed for new content
}
register_activation_hook( __FILE__, function () {
if ( ! wp_next_scheduled( 'tw_check_external_feed_event' ) ) {
wp_schedule_event( time(), 'every_fifteen_minutes', 'tw_check_external_feed_event' );
}
} );
The cron_schedules filter must run on every request, not just once at activation, since WordPress needs to know what every_fifteen_minutes means every time it checks the schedule. That's why the filter is registered unconditionally at the top level of your file, while the actual scheduling call stays inside the activation hook.
Scheduling a One-Time Task
Not every scheduled task needs to repeat. wp_schedule_single_event() queues a task to run once, at a specific point in the future, which is the right tool for something like sending a follow-up email a day after a user signs up:
add_action( 'tw_send_welcome_email_event', 'tw_send_welcome_email', 10, 1 );
function tw_send_welcome_email( $user_id ) {
$user = get_userdata( $user_id );
if ( $user ) {
wp_mail(
$user->user_email,
'Welcome!',
'Thanks for signing up, ' . $user->display_name . '. Let us know if you have any questions.'
);
}
}
add_action( 'user_register', function ( $user_id ) {
wp_schedule_single_event( time() + HOUR_IN_SECONDS, 'tw_send_welcome_email_event', [ $user_id ] );
} );
Two details matter here: wp_schedule_single_event() accepts an array of arguments as its third parameter, which get passed straight through to your hooked function, exactly the way $user_id arrives in tw_send_welcome_email() above. And unlike wp_schedule_event(), there's no recurring schedule to unschedule afterward; once a single event fires, WordPress removes it from the cron table on its own.
Why the Default WP-Cron Trigger Falls Short
All of the scheduling code above works correctly regardless of how WP-Cron is triggered, the API itself doesn't change. What changes is when your tasks actually run. With the default page-load trigger:
- Low-traffic sites see delayed execution, sometimes by hours, since a task only runs when a page load happens to occur after its scheduled time.
- Full-page caching can suppress the trigger entirely. If a caching layer serves pages without WordPress ever bootstrapping PHP for that request, the check that fires WP-Cron never runs.
- High-traffic sites see the opposite problem: excessive overhead. Every single page load, whether or not anything is actually due, triggers a check and an extra background HTTP request to
wp-cron.php, adding unnecessary load on busy sites.
This is exactly the kind of timing drift that matters for anything time-sensitive, a scheduled backup (see our guide on how to create a WordPress backup manually if you're setting one up for the first time), a scheduled post that needs to publish at a specific time, or a security scan you want running reliably overnight rather than whenever the next visitor happens by.
Switching to a Real System Cron Job
The fix is to stop relying on visitor traffic entirely: disable WP-Cron's built-in page-load trigger, and replace it with an actual cron job on your server that hits wp-cron.php directly, on a real, fixed schedule.
Step 1: Disable the Default Trigger
Add this to wp-config.php, above the /* That's all, stop editing! */ line:
define( 'DISABLE_WP_CRON', true );
This stops WordPress from checking for due tasks on every page load. Importantly, it doesn't disable the cron system itself, your scheduled events, custom intervals, and hooked functions from earlier in this guide all keep working exactly the same; it only removes the page-load trigger that used to run them.
Step 2: Set Up a Real Cron Job on Your Server
With the automatic trigger disabled, something else needs to call wp-cron.php on a schedule. If you have SSH access to your server, open your crontab with crontab -e and add a line like:
*/15 * * * * wget -q -O - "https://example.com/wp-cron.php?doing_wp_cron" >/dev/null 2>&1
Or, using curl instead of wget, whichever is available on your server:
*/15 * * * * curl -s "https://example.com/wp-cron.php?doing_wp_cron" >/dev/null 2>&1
Replace example.com with your actual domain. This example runs every 15 minutes (*/15 * * * *), which is a reasonable default for most sites: frequent enough that scheduled tasks fire close to on time, infrequent enough not to add meaningful server load. Adjust the interval based on how time-sensitive your scheduled tasks actually are; a site relying heavily on every_fifteen_minutes-style custom intervals should run the system cron at least that often, since WP-Cron can never fire more frequently than the system trigger calling it.
If your host provides a control panel like cPanel instead of direct SSH access, look for a Cron Jobs section there; it wraps the same underlying crontab mechanism in a form-based interface where you set the schedule and paste in the same wget or curl command shown above.
Step 3: Verify It's Actually Running
After switching over, confirm scheduled tasks are still firing on time. WP-Crontrol (a plugin available on WordPress.org) lists every currently scheduled cron event, including its next run time and which hook it's tied to, letting you compare "when it says it should run" against "when it actually did" using your server's cron logs or an audit log from whatever task you scheduled.
Frequently Asked Questions (FAQ) About WordPress Cron Jobs
No, not by itself. DISABLE_WP_CRON only removes the page-load trigger; scheduled posts, plugin update checks, and every other cron-driven task still exist as scheduled events. They simply stop running until something else, your new system cron job, calls wp-cron.php on a fixed schedule instead.
Every scheduled task on your site stops running entirely, since nothing is left to trigger wp-cron.php. This includes scheduled posts, which will sit in "Scheduled" status past their publish time, and any plugin relying on cron for update checks or maintenance tasks. Always set up the replacement cron job in the same session you add the constant, and verify it's working before moving on.
Every 15 minutes is a reasonable default for most sites. If you're not running any custom intervals more frequent than that, you could safely go to every 30 minutes or hourly; if you have a custom interval scheduled more frequently than your system cron runs, that task will never fire on time no matter how correctly it's registered in code, since the system cron is now the only thing capable of triggering it.
No, wp_next_scheduled() only tells you whether an event is scheduled for the future and when, not whether a past run succeeded or failed. For that, log the outcome yourself inside the hooked function (writing to a custom database table, an option, or a file), or use a plugin like WP-Crontrol that shows scheduled events and lets you manually trigger one to test it.
Yes, visiting https://example.com/wp-cron.php?doing_wp_cron directly in a browser (or via curl) manually triggers a cron check the same way a system cron job does, which makes it a useful way to test that a scheduled task fires correctly before waiting for its real scheduled time.
This almost always means the scheduling code is missing its wp_next_scheduled() guard, so it's calling wp_schedule_event() again on every activation, or worse, on every page load if it's not wrapped in an activation hook at all. Remove the duplicates with wp_clear_scheduled_hook( 'your_event_name' ), then make sure the scheduling call is properly guarded before re-adding it.
It can, particularly full-page caching that serves pages entirely from the cache without WordPress bootstrapping PHP for that request, which means the default page-load trigger never runs on cached page views. This is one of the more common reasons cron tasks silently stop running on sites that recently added aggressive caching, and it's a strong argument for switching to a real system cron job rather than depending on cache-miss traffic to keep tasks on schedule.
Conclusion
WP-Cron's page-load design is a reasonable default for sites without server-level cron access, but it trades reliability for convenience, and that trade-off becomes a real problem the moment a scheduled task needs to run at a predictable time rather than "eventually, whenever the next visitor shows up." The scheduling API itself, wp_schedule_event(), wp_schedule_single_event(), wp_next_scheduled(), and a hooked add_action() callback, is solid and doesn't need to change regardless of what triggers it.
What's worth changing, for any site running scheduled backups, time-sensitive posts, or recurring maintenance tasks, is the trigger itself: disable it with DISABLE_WP_CRON, and replace it with a real system cron job hitting wp-cron.php on a fixed interval. It's a five-minute change that removes an entire category of "why didn't this run on time" debugging later.
Here are a few additional resources worth reading as you build out more scheduled tasks:
- WordPress Plugin Handbook: Cron — the official guide to the WP-Cron API, including all built-in hooks and functions.
- WordPress Developer Reference: wp_schedule_event() — full parameter reference and usage notes.
- WordPress VIP Documentation: Cron on WordPress VIP — how cron is handled at scale, useful context even for smaller, self-hosted sites.


