Why WP-Cron is the Wrong Tool for Heavy Jobs
WordPress’s built-in scheduler, WP-Cron, isn’t a real cron job — it’s a simulation. Each time a visitor loads a page, WordPress checks whether any scheduled tasks are due. That design has three consequences that matter for anything resource-intensive:
- Request-dependent execution. If traffic is low, scheduled jobs sit in queue for hours. A task set to run at 2am on a low-traffic site may not fire until the next morning visitor arrives.
- Runs inside the user’s request. When a task does fire, it executes within that page load, consuming server resources and slowing down the response for that visitor. This is the most common cause of intermittent timeouts on WordPress sites doing bulk operations.
- No retry logic. If a WP-Cron task fails halfway through, nothing retries it. The failure is silent.
For any job that takes more than a few seconds — API syncs, bulk email, image processing, CSV imports — WP-Cron is the wrong tool. You need a proper async queue.
Action Scheduler: WordPress’s Async Task Queue
The Action Scheduler library is the de facto standard for background processing in the WordPress ecosystem. Originally built by WooCommerce, it’s now independently maintained and used by dozens of major plugins. If you have WooCommerce installed, Action Scheduler is already bundled. For other projects, install it via Composer (see the Composer for WordPress guide if you’re not already using it):
composer require woocommerce/action-scheduler
Then load the library in your plugin’s main file:
// In your plugin's main file
require_once plugin_dir_path( __FILE__ ) . 'vendor/woocommerce/action-scheduler/action-scheduler.php';
Action Scheduler stores queued jobs in dedicated database tables (actionscheduler_actions, actionscheduler_logs, actionscheduler_groups) rather than the crowded wp_options table WP-Cron uses. Every action has a status (pending, running, complete, failed), a log of its execution history, and automatic retry on failure.
Three Functions, Three Use Cases
Action Scheduler provides three scheduling functions that mirror WordPress’s native wp_schedule_* API but are backed by the queue:
| Function | When to use |
|---|---|
as_enqueue_async_action() |
Run a job in the background immediately — decouple the work from the current request |
as_schedule_single_action() |
Run a job once at a specific Unix timestamp in the future |
as_schedule_recurring_action() |
Run a job on a repeating interval (like a real cron job) |
Async action: decouple heavy work from the request
Use as_enqueue_async_action() whenever a user action (form submit, order placement, import trigger) kicks off work that should happen in the background while the user sees an immediate response:
// Register the callback that does the heavy work
function my_plugin_process_api_sync( $user_id ) {
$records = fetch_records_from_api( $user_id );
foreach ( $records as $record ) {
update_user_meta( $record['id'], 'synced_data', $record );
}
error_log( 'API sync complete for user ' . $user_id );
}
add_action( 'my_plugin/api_sync', 'my_plugin_process_api_sync' );
// Queue the task immediately — user's page load returns before the work starts
function my_plugin_trigger_sync( $user_id ) {
as_enqueue_async_action(
'my_plugin/api_sync',
[ 'user_id' => $user_id ],
'my-plugin' // group name — visible in WooCommerce → Action Scheduler
);
}
The user’s page load returns in milliseconds. The API sync runs in the background on the next queue pass, with a full execution log and automatic retry if it fails.
Single action: delayed execution
Use as_schedule_single_action() for time-delayed jobs — reminder emails, license expiry notifications, post-publishing follow-ups:
// Send a reminder email 24 hours from now
function my_plugin_schedule_reminder( $order_id ) {
if ( ! as_has_scheduled_action( 'my_plugin/order_reminder', [ 'order_id' => $order_id ] ) ) {
as_schedule_single_action(
time() + DAY_IN_SECONDS,
'my_plugin/order_reminder',
[ 'order_id' => $order_id ],
'my-plugin'
);
}
}
add_action( 'woocommerce_checkout_order_created', 'my_plugin_schedule_reminder' );
The as_has_scheduled_action() guard prevents duplicate queue entries if the trigger fires more than once for the same order.
Recurring action: replace WP-Cron schedules
Use as_schedule_recurring_action() to replace any wp_schedule_event() calls you have today — it gives you the same scheduling but with a real queue, execution logs, and fault tolerance:
// Register a recurring daily report — run once at startup, not on every request
function my_plugin_schedule_recurring_jobs() {
if ( ! as_has_scheduled_action( 'my_plugin/daily_report' ) ) {
as_schedule_recurring_action(
strtotime( 'tomorrow midnight' ),
DAY_IN_SECONDS,
'my_plugin/daily_report',
[],
'my-plugin'
);
}
}
add_action( 'init', 'my_plugin_schedule_recurring_jobs' );
function my_plugin_generate_daily_report() {
// ... generate and email the report ...
}
add_action( 'my_plugin/daily_report', 'my_plugin_generate_daily_report' );
The as_has_scheduled_action() guard on init ensures you only register the recurring job once, not on every page load.
Batch Processing Large Datasets
For jobs that process thousands or millions of records — CSV imports, bulk syncs, inventory updates — never try to process everything in a single action. Use a self-scheduling batch loop: each action processes a fixed chunk of records, then queues the next chunk immediately before returning.
// Kick off the batch job — only queue the first batch from a user-facing request
function my_plugin_start_import( $file_path ) {
as_enqueue_async_action(
'my_plugin/process_batch',
[ 'file' => $file_path, 'offset' => 0, 'batch_size' => 100 ],
'my-plugin-import'
);
}
// Each batch processes 100 rows, then queues the next batch
function my_plugin_process_batch( $file, $offset, $batch_size ) {
$rows = read_csv_rows( $file, $offset, $batch_size );
foreach ( $rows as $row ) {
import_single_row( $row );
}
if ( count( $rows ) === $batch_size ) {
// More rows remain — queue the next batch immediately
as_enqueue_async_action(
'my_plugin/process_batch',
[ 'file' => $file, 'offset' => $offset + $batch_size, 'batch_size' => $batch_size ],
'my-plugin-import'
);
}
}
add_action( 'my_plugin/process_batch', 'my_plugin_process_batch', 10, 3 );
This pattern is non-blocking (no single action runs for more than a few seconds), fault-tolerant (a failed batch only loses that chunk, not the entire import), and scalable to any volume. The batches process in parallel if your server has Action Scheduler’s concurrent runner enabled (it runs up to 5 concurrent batches by default).
Monitoring and Cancelling Queued Actions
Action Scheduler gives you programmatic control over the queue — useful for deduplication, cancellation flows, and debugging:
// Check whether an action is already queued before adding a duplicate
$is_pending = as_has_scheduled_action( 'my_plugin/api_sync', [ 'user_id' => 45 ] );
// Cancel all pending actions for a hook + args combination
as_unschedule_all_actions( 'my_plugin/api_sync', [ 'user_id' => 45 ], 'my-plugin' );
// Inspect the queue programmatically
$actions = as_get_scheduled_actions(
[
'hook' => 'my_plugin/api_sync',
'status' => ActionScheduler_Store::STATUS_PENDING,
'group' => 'my-plugin',
'per_page' => 20,
],
'ARRAY_A'
);
You can also inspect and manage the queue directly from WooCommerce → Action Scheduler in wp-admin — it shows every pending, running, failed, and completed action with full execution logs. For WP-CLI users, the Action Scheduler CLI commands are a faster alternative for local development and CI environments:
# List all pending actions for your plugin's group
wp action-scheduler list --group=my-plugin --status=pending
# Run the queue manually (useful for local dev or CI)
wp action-scheduler run
# Cancel a specific action by ID
wp action-scheduler cancel --hook=my_plugin/api_sync
Replace WP-Cron with a Real System Cron
Action Scheduler processes its own queue reliably, but it still needs something to trigger its runner on a schedule. By default, that trigger is WP-Cron — which means if no visitors arrive, nothing runs. Fix this in two steps.
Step 1: Disable the HTTP-triggered WP-Cron in wp-config.php:
// wp-config.php — disable the HTTP-triggered WP-Cron
define( 'DISABLE_WP_CRON', true );
Step 2: Add a real system cron job via crontab -e on your server:
# Run wp-cron.php every 5 minutes via system cron
*/5 * * * * curl -s https://example.com/wp-cron.php?doing_wp_cron > /dev/null 2>&1
This ensures the Action Scheduler queue runner fires every 5 minutes regardless of traffic, with no user request bearing the overhead. On managed WordPress hosts (Kinsta, WP Engine, Cloudways), check your hosting dashboard — most provide a system cron configuration panel that handles this without server SSH access.
Action Scheduler Quick-Start Checklist
- Install Action Scheduler via Composer or confirm it’s bundled with WooCommerce
- Use
as_enqueue_async_action()for any job that would otherwise block a user request - Use
as_schedule_single_action()for time-delayed jobs (reminders, follow-ups) - Use
as_schedule_recurring_action()to replace everywp_schedule_event()call in your plugin - Add
as_has_scheduled_action()guards before every schedule call to prevent duplicate queue entries - Batch large datasets — process a fixed chunk per action and self-schedule the next batch
- Assign every action to a group name so they’re identifiable in the wp-admin Action Scheduler log
- Add
define( ‘DISABLE_WP_CRON’, true );towp-config.php - Configure a real system cron job to trigger
wp-cron.phpevery 5 minutes - Verify the queue in WooCommerce → Action Scheduler or via
wp action-scheduler list
Background processing is the foundation that makes everything else on a high-traffic site stay fast. When a custom REST API endpoint receives a request to kick off an import or a sync, the endpoint should queue an Action Scheduler job and return immediately — the actual work runs out of band. Combine that with the Transient API to cache the processed results and you get a fully decoupled pipeline: fast responses, reliable execution, and cached reads. For sites where background job throughput is a performance constraint, the WordPress performance guide covers the broader set of server-side optimizations that complement async processing.


