WordPress Background Processing
Model note: Scaffolding a new Action Scheduler or
WP_Background_Processimplementation is pattern-matching —haikuhandles it well. Debugging a stuck queue or race condition across async jobs requires reasoning; usesonnet.
Implement background jobs and queued tasks in WordPress plugins. Three primary tools with distinct trade-offs: Action Scheduler (persistent, battle-tested), WP_Background_Process (lightweight, no DB table), WP Cron (built-in, unreliable timing).
When to use
- "Process a large batch of items in the background", "queue a long-running task".
- "Set up Action Scheduler", "use WooCommerce queue".
- "Implement WP_Background_Process", "chunked batch import".
- "Background email sending", "async API calls".
- "Fix a WP Cron job not firing", "make scheduled tasks reliable".
- "The scheduled action completes but nothing happens", "the handler only runs in the admin".
Not for: One-off scheduled events (use wp_schedule_single_event). REST API async patterns — use wp-rest-api.
Method
1. Choose the right tool
| Tool | Persistent | Retry | Progress | Requires | Best for |
|---|---|---|---|---|---|
| Action Scheduler | ✅ DB table | ✅ Configurable | Via hooks | AS or WC | Reliable multi-step queues |
| WP_Background_Process | ✅ Options API | ❌ Manual | Via option | ~5KB class | Simple batches, no WC dep |
| WP Cron | ❌ (transient) | ❌ | ❌ | Nothing | Maintenance, low-priority tasks |
Rule of thumb: WooCommerce already installed → Action Scheduler. Simple background batch → WP_Background_Process. Periodic cleanup task → WP Cron.
2. Action Scheduler
Bundled with WooCommerce. Can also be installed as a standalone library.
Standalone install:
composer require woocommerce/action-scheduler
require_once plugin_dir_path( __FILE__ ) . 'vendor/woocommerce/action-scheduler/action-scheduler.php';
Schedule and handle actions:
// Schedule a single action (fires once ASAP)
as_enqueue_async_action( 'my_plugin_process_item', [ 'item_id' => 123 ], 'my-plugin' );
// Schedule a recurring action
as_schedule_recurring_action( time(), HOUR_IN_SECONDS, 'my_plugin_hourly_sync', [], 'my-plugin' );
// Schedule a single action in the future
as_schedule_single_action( time() + 300, 'my_plugin_delayed_job', [ 'batch' => 1 ], 'my-plugin' );
// Handle the action
add_action( 'my_plugin_process_item', function( $item_id ) {
$result = my_plugin_do_work( $item_id );
if ( is_wp_error( $result ) ) {
// AS will retry on exception; throw to trigger retry
throw new \Exception( $result->get_error_message() );
}
} );
Batch queue (fan-out pattern):
function my_plugin_queue_all_items() {
$items = get_posts( [ 'post_type' => 'my_type', 'posts_per_page' => -1, 'fields' => 'ids' ] );
foreach ( $items as $id ) {
// Skip if already scheduled
if ( ! as_has_scheduled_action( 'my_plugin_process_item', [ 'item_id' => $id ], 'my-plugin' ) ) {
as_enqueue_async_action( 'my_plugin_process_item', [ 'item_id' => $id ], 'my-plugin' );
}
}
}
Cancel scheduled actions:
as_unschedule_action( 'my_plugin_hourly_sync', [], 'my-plugin' );
as_unschedule_all_actions( 'my_plugin_process_item', [], 'my-plugin' );
Monitor via WP-CLI:
wp action-scheduler list --group=my-plugin --status=pending
wp action-scheduler run --group=my-plugin
3. WP_Background_Process
Lightweight 2-class library using WP options + transients for queue state. No extra DB table.
composer require deliciousbrains/wp-background-processing
Extend the class:
class My_Plugin_Batch_Process extends WP_Background_Process {
protected $action = 'my_plugin_batch'; // Unique key — used for cron and option names
protected function task( $item ) {
// Process one item. Return false to remove from queue; return $item to re-queue.
$result = my_plugin_process( $item['id'] );
if ( is_wp_error( $result ) ) {
// Log and skip — returning $item would re-queue endlessly
error_log( 'my-plugin: failed item ' . $item['id'] . ': ' . $result->get_error_message() );
return false;
}
return false; // Done — remove from queue
}
protected function complete() {
parent::complete();
update_option( 'my_plugin_batch_complete', time() );
do_action( 'my_plugin_batch_complete' );
}
}
Push items and dispatch:
$process = new My_Plugin_Batch_Process();
$items = get_ids_to_process();
foreach ( $items as $id ) {
$process->push_to_queue( [ 'id' => $id ] );
}
$process->save()->dispatch();
Check status:
if ( $process->is_queue_empty() ) { /* done */ }
4. WP Cron
Built-in. Fires on page load — unreliable on low-traffic sites. Use for non-critical periodic tasks.
// Register custom interval
add_filter( 'cron_schedules', function( $schedules ) {
$schedules['every_15_minutes'] = [
'interval' => 15 * MINUTE_IN_SECONDS,
'display' => __( 'Every 15 Minutes', 'my-plugin' ),
];
return $schedules;
} );
// Schedule on activation; clear on deactivation
register_activation_hook( __FILE__, function() {
if ( ! wp_next_scheduled( 'my_plugin_cron_job' ) ) {
wp_schedule_event( time(), 'every_15_minutes', 'my_plugin_cron_job' );
}
} );
register_deactivation_hook( __FILE__, function() {
wp_clear_scheduled_hook( 'my_plugin_cron_job' );
} );
// Handle
add_action( 'my_plugin_cron_job', function() {
my_plugin_do_maintenance();
} );
Make WP Cron reliable on low-traffic sites: Set up a real system cron to call wp-cron.php and disable the page-load trigger:
# System cron (every 5 minutes)
*/5 * * * * wget -q -O - https://example.com/wp-cron.php?doing_wp_cron >/dev/null 2>&1
// wp-config.php
define( 'DISABLE_WP_CRON', true );
5. Progress tracking
Track batch progress for admin UI display:
// Increment progress in task()
protected function task( $item ) {
$progress = get_option( 'my_plugin_batch_progress', [ 'done' => 0, 'total' => 0 ] );
// ... do work ...
$progress['done']++;
update_option( 'my_plugin_batch_progress', $progress );
return false;
}
// Poll via REST or AJAX
add_action( 'wp_ajax_my_plugin_batch_progress', function() {
wp_send_json_success( get_option( 'my_plugin_batch_progress', [ 'done' => 0, 'total' => 0 ] ) );
} );
6. Error handling patterns
Action Scheduler retries on uncaught exceptions. Control retry behaviour:
// Fail permanently (no retry)
ActionScheduler_Logger::instance()->log( $action_id, 'Permanent failure: ' . $reason );
throw new ActionScheduler_InvalidActionException( $reason );
// Retry with delay (reschedule from handler)
as_schedule_single_action( time() + 300, 'my_plugin_process_item', $args, 'my-plugin' );
return; // Don't throw — AS won't auto-retry this run
7. Register the handler where the job actually runs
Background contexts run with is_admin() === false:
| Context | is_admin() |
|---|---|
WP-Cron (wp-cron.php, system cron) |
false |
| Action Scheduler queue runner | false |
| Payment gateway / third-party webhooks | false |
| WP-CLI | false |
| REST API request | false |
admin-ajax.php |
true |
So a handler registered inside an admin-only bootstrap never fires for the job it was written for:
// Bootstrap — handler is unreachable from the queue
if ( is_admin() ) {
new My_Plugin\Admin\Controller(); // registers the AS handler in its constructor
}
This fails silently and looks healthy from the admin: the action schedules, the row
lands in actionscheduler_actions, the queue runner claims it, and — with no
listener attached — marks it complete. Nothing throws, nothing logs.
Register anything a background context must reach from an always-loaded bootstrap:
// Always loaded — admin, front-end, cron, CLI, webhooks
new My_Plugin\Common\Queue_Controller();
if ( is_admin() ) {
new My_Plugin\Admin\Controller(); // admin screens only
}
The same trap catches admin_init (never fires in cron), current_screen, and any
registration behind a capability check — the queue runner has no logged-in user.
Detect it. Grep the callback, then check what loads the file that registers it:
grep -rn "add_action( 'my_plugin_process_item'" .
# → is that class instantiated behind is_admin(), a screen check,
# a current_user_can() gate, or hooked to admin_init?
Verify from outside the admin — an admin page load proves nothing here:
wp eval "do_action( 'my_plugin_process_item', 123 );" # is_admin() is false
wp action-scheduler run --group=my-plugin
Guard for re-entry. A context you don't control can deliver the same job more than once — webhook retries, a manual and an automatic path both completing, an action rescheduled after a partial failure. Make the side effect idempotent with a persisted flag instead of assuming one delivery:
add_action( 'my_plugin_credit_order', function ( $order_id ) {
$order = wc_get_order( $order_id );
if ( ! $order || $order->get_meta( '_my_plugin_credited' ) ) {
return; // already processed
}
my_plugin_credit( $order );
$order->update_meta_data( '_my_plugin_credited', 1 );
$order->save();
} );
Notes
- Handlers for background work must be registered outside
is_admin()— see §7. The failure is silent, so a "the job never runs" report starts by checking where the handler is registered, not what it does. - Action Scheduler stores pending/failed actions in
{prefix}actionscheduler_actionstable — visible in WC → Status → Scheduled Actions. Check there first when debugging stuck jobs. - WP Cron events do not persist across deactivation — always clear on
register_deactivation_hook. - Background processes that modify many posts/options should run in small chunks (50–100 items) to avoid timeout and memory limits. Use
$process->memory_exceeded()and$process->time_exceeded()checks fromWP_Background_Processto self-limit. - On multisite: Action Scheduler is per-site. For network-wide jobs, run from the main site or loop via
switch_to_blog().
References
references/action-scheduler-api.md— Action Scheduler API: scheduling functions, queue management, batch operations, status constants, and WP Cron integrationreferences/batch-patterns.md— Chunked processing patterns: chunk size rules, memory/time guards,WP_Background_Processsubclass scaffold, and queue drain loopreferences/wp-cron-intervals.md— WP Cron built-in intervals, custom interval registration, debug commands, and cron health-check tools