The problem with WordPress scheduled tasks
Most WordPress developers inherit or build sites that run background jobs—sending emails after orders, purging old transients, syncing data with external APIs. WordPress provides WP-Cron to handle this, but many teams don't realize it's fundamentally unreliable in production. WP-Cron explained: WordPress best practices explained starts with understanding that the default implementation is a trap, and knowing when and how to replace it with real infrastructure.
The gap between what WP-Cron promises and what it delivers has caught us all at some point. A client's abandoned cart emails never send. A nightly report fails silently for weeks. Cache never clears. Understanding why—and how to fix it—is essential for anyone running production WordPress at scale.
How WP-Cron really works (and why it fails)
WordPress doesn't have a true scheduler. Instead, WP-Cron is triggered by page visits. When someone loads your site, WordPress checks if any scheduled tasks are due. If they are, they run during that request.
This approach has several fatal flaws:
- No execution guarantee. If nobody visits your site for hours, scheduled tasks don't run. A store sleeping overnight won't process queued jobs.
- Request blocking. If a task takes 30 seconds and it runs during a visitor's page load, that visitor waits 30 seconds for a response. This tanks user experience and can trigger timeouts.
- Race conditions. Multiple concurrent visitors can all trigger the same task. Your "send weekly digest" job runs three times in parallel.
- Loopback requests fail. WP-Cron works by making an HTTP request to your site from itself. If your firewall, CDN, or server config blocks loopback requests, nothing runs.
Production environments almost always have at least one of these issues. We've debugged countless "why doesn't my plugin work?" cases only to find WP-Cron was never firing.
Disable WP-Cron and use a real scheduler
The first step is to disable the built-in scheduler entirely and replace it with a real cron job or task runner.
Add this to wp-config.php:
define( 'DISABLE_WP_CRON', true );Then set up a real cron job on your server. For a typical WordPress host, this looks like:
*/5 * * * * curl -s https://yoursite.com/wp-cron.php?doing_wp_cron > /dev/null 2>&1Run this every 5 minutes. WordPress will check which tasks are due when that URL is called, independent of visitor traffic.
If you're on AWS, Lambda, or a modern containerized setup, use your platform's native scheduler:
- AWS EventBridge: Trigger a Lambda function on a schedule that calls your WordPress site or directly invokes a callback.
- Google Cloud Scheduler: Set up an HTTP target pointing to your cron endpoint.
- Docker/Kubernetes: Run a sidecar container with a cron entry that calls the endpoint.
The key is: your scheduler should not depend on visitor traffic. It should be independent and reliable.
Using wp_schedule_event for recurring jobs
Once WP-Cron is properly configured (or you're using a real cron), you use wp_schedule_event() to register tasks in your plugin or theme.
A typical setup for a recurring job:
// In plugin activation or admin_init
if ( ! wp_next_scheduled( 'my_hourly_sync' ) ) {
wp_schedule_event( time(), 'hourly', 'my_hourly_sync' );
}
// In your plugin code, hook the action
add_action( 'my_hourly_sync', 'perform_data_sync' );
function perform_data_sync() {
// Fetch data from API, update posts, etc.
$response = wp_remote_get( 'https://api.example.com/data' );
if ( is_wp_error( $response ) ) {
error_log( 'Sync failed: ' . $response->get_error_message() );
return;
}
$data = json_decode( wp_remote_retrieve_body( $response ) );
// Process and store data...
}A few important patterns:
- Check before scheduling. Use
wp_next_scheduled()to avoid duplicate entries. This matters during plugin activation. - Use standard intervals. WordPress provides
hourly,twicedaily, anddaily. For custom intervals, register them withcron_schedulesfilter. - Always hook the action. The scheduling just registers when it runs. The
add_action()defines what actually happens. - Log failures. Use
error_log()or a logging service. Silent failures are worse than loud ones.
For a custom interval (every 15 minutes, for example):
add_filter( 'cron_schedules', 'add_custom_cron_intervals' );
function add_custom_cron_intervals( $schedules ) {
$schedules['every_15_minutes'] = array(
'interval' => 15 * MINUTE_IN_SECONDS,
'display' => __( 'Every 15 Minutes' ),
);
return $schedules;
}
// Then schedule with
wp_schedule_event( time(), 'every_15_minutes', 'my_frequent_task' );Action Scheduler for heavy workloads and queues
When you have large batches of work—processing 10,000 orders, sending thousands of emails, updating inventory across multiple stores—WP-Cron and even wp_schedule_event() aren't enough. You need a queue.
Action Scheduler is a library (used by WooCommerce, WP Job Manager, and others) that decouples task scheduling from execution. Instead of running everything at once, tasks are queued and processed in batches.
Install it via Composer in your plugin:
composer require woocommerce/action-schedulerThen use it to queue work:
// Queue a single action to run in 5 minutes
as_schedule_single_action(
time() + ( 5 * MINUTE_IN_SECONDS ),
'send_customer_email',
array( 'customer_id' => 123 )
);
// Queue a recurring action (every hour, starting now)
as_schedule_recurring_action(
time(),
HOUR_IN_SECONDS,
'sync_inventory',
array()
);
// Hook the action
add_action( 'send_customer_email', 'handle_customer_email' );
function handle_customer_email( $customer_id ) {
$customer = get_user_by( 'id', $customer_id );
wp_mail( $customer->user_email, 'Hello', 'Your order shipped!' );
}Why Action Scheduler is better for heavy work:
- Batching. It processes multiple queued items in a single execution window, reducing overhead.
- Resilience. Failed actions are retried automatically. Logging is built in.
- Concurrency control. You can limit how many of the same action run in parallel.
- Database-backed. Actions are stored in the database, so you can inspect, retry, or cancel them manually.
- Priority queuing. Schedule high-priority work to run first.
For email campaigns, bulk data exports, or third-party API syncs with many items, Action Scheduler prevents your site from grinding to a halt during peak execution windows.
Common mistakes
Not testing locally. Many developers never verify that scheduled tasks actually run. Set up a real cron job in your local environment (or use a service like EasyCron for testing). If you can't see it work locally, it won't work in production.
Forgetting to handle errors. A scheduled task that throws an exception and stops running silently is invisible to everyone except your error logs. Always wrap key logic in try-catch and log outcomes.
Running expensive tasks on single visitors. If your hourly job takes 20 seconds and you haven't disabled WP-Cron, you're making random visitors wait 20 seconds. This is a hidden performance killer.
Scheduling inside hooks that run frequently. Don't call wp_schedule_event() inside wp_footer or init unless you check wp_next_scheduled() first. You'll register the same task dozens of times per page load.
Assuming WP-Cron is working without verification. Add a simple admin notice or debug log message to confirm tasks ran. We've seen sites where nothing scheduled executed for months unnoticed.
Key takeaways
- WP-Cron is not reliable in production because it depends on visitor traffic. Disable it with
DISABLE_WP_CRONand use a real server cron or platform scheduler. - Use
wp_schedule_event()for straightforward recurring tasks like syncs or cleanups, but always verify it's actually running. - For high-volume work—thousands of emails, large batch exports, queued API calls—use Action Scheduler instead. It batches, retries, and logs automatically.
- Always log failures and test your scheduled tasks locally before pushing to production.
- Combine a real cron job with proper action hooks to build a reliable background task system that scales with your WordPress site.






Comments
Loading comments…