Background processing with Action Scheduler in WordPress
When you're running large operations in WordPress—syncing product catalogs, processing orders, sending bulk emails—you can't afford to block the user's request. Background processing with Action Scheduler in WordPress lets you offload these tasks to a reliable queue that executes asynchronously. If you've used WooCommerce or worked on sites that handle high volume, you've likely benefited from Action Scheduler without realizing it.
What it is — A job queue used by WooCommerce
Action Scheduler is a job queue library designed to handle background tasks in WordPress. Rather than executing long-running operations synchronously during a request, you queue an action and let the system process it later. It's built into WooCommerce and powers everything from subscription renewals to webhook retries.
Think of it as a postbox. Instead of waiting for the mail carrier to come to your door (blocking your request), you drop your letter in the box and they collect it periodically. The user gets their response immediately, and your work completes in the background.
Action Scheduler stores scheduled actions in the WordPress database and processes them via:
- Loopback HTTP requests (most common in shared hosting)
- WP-Cron (fallback, less reliable)
- Custom runners (if you control your server)
For production sites handling real commerce or SaaS workloads, you'll want to ensure reliable processing. Many teams implement custom runners or use third-party services to poll Action Scheduler's queue.
Schedule actions — as_enqueue_async_action and recurring actions
The core function is as_enqueue_async_action(). It's the simplest way to queue work for later.
// Queue an action to run as soon as possible
as_enqueue_async_action( 'custom_sync_products', array( $store_id ) );
// In your action handler
add_action( 'custom_sync_products', function( $store_id ) {
// This runs in the background, not blocking the original request
sync_external_product_catalog( $store_id );
});
For scheduled work (like nightly cleanup), use as_schedule_recurring_action().
// Schedule a recurring action
if ( ! as_next_scheduled_action( 'daily_inventory_audit' ) ) {
as_schedule_recurring_action(
time(), // Start time
24 * HOUR_IN_SECONDS, // Interval (24 hours)
'daily_inventory_audit', // Hook name
array(), // Arguments
'inventory-group' // Group (optional)
);
}
// Handler
add_action( 'daily_inventory_audit', function() {
check_inventory_levels();
send_low_stock_alerts();
});
You can also schedule one-off actions for a specific time:
// Run 5 minutes from now
as_schedule_single_action(
time() + 5 * MINUTE_IN_SECONDS,
'send_order_confirmation',
array( $order_id )
);
When designing your handlers, assume they might run multiple times or be retried. Use idempotent operations: if you run the same action twice, the end state should be identical.
Bad approach: Incrementing a counter without checking state. Good approach: Setting a flag or using database transactions to ensure the operation completes only once.
Admin UI — See pending, failed and completed jobs
Action Scheduler includes a admin page where you can inspect queued actions. In WordPress admin, navigate to Tools > Scheduled Actions (if you've installed Action Scheduler as a standalone plugin or it's active via WooCommerce).
The interface shows you:
- Pending actions: Work waiting to be processed
- In-progress actions: Currently running
- Failed actions: Errored out and stopped
- Completed actions: Successfully finished
You can manually trigger an action, delete it, or re-queue failed ones. This is invaluable when debugging. If a bulk import fails halfway through, you can inspect which actions errored, fix the underlying issue, and re-run them.
From code, you can also query the schedule directly:
// Find pending actions
$actions = as_get_scheduled_actions( array(
'hook' => 'custom_sync_products',
'status' => ActionScheduler_Store::STATUS_PENDING,
), 'objects' );
foreach ( $actions as $action ) {
echo $action->get_hook() . ' - ' . $action->get_schedule()->get_date() . "\n";
}
In high-volume environments, this dashboard becomes your operations cockpit. You'll monitor queue depth, spot patterns in failures, and validate that your background work is completing.
Scale — Batch large imports into many small actions
A common mistake is queuing one massive action for a bulk operation. If you're importing 50,000 products, one action that processes all 50k will timeout and fail.
Instead, batch your work into smaller chunks:
// Bulk import entry point (user clicks Import button)
function trigger_product_import( $feed_url ) {
$products = fetch_products_from_feed( $feed_url );
$batch_size = 50;
$batches = array_chunk( $products, $batch_size );
foreach ( $batches as $index => $batch ) {
as_enqueue_async_action(
'import_product_batch',
array( $batch, $index ),
'product-import'
);
}
}
// Handler processes one batch
add_action( 'import_product_batch', function( $batch, $index ) {
foreach ( $batch as $product_data ) {
$product = new WC_Product_Simple();
$product->set_name( $product_data['name'] );
$product->set_price( $product_data['price'] );
$product->save();
}
// Log progress
error_log( "Completed batch {$index}" );
}, 10, 2 );
By grouping actions with 'product-import', you can monitor and control the entire import job as a logical unit. You can also rate-limit by group, preventing import batches from overwhelming your database.
For truly large operations (millions of records), pair this with a custom table for tracking progress rather than relying solely on action arguments:
// Store import metadata in a custom table
function start_import( $feed_url ) {
global $wpdb;
$import_id = wp_generate_uuid4();
$wpdb->insert(
'wp_import_jobs',
array(
'import_id' => $import_id,
'feed_url' => $feed_url,
'status' => 'pending',
'created_at' => current_time( 'mysql' ),
)
);
// Enqueue initial batch
as_enqueue_async_action( 'fetch_import_batch', array( $import_id, 0 ) );
}
// Fetch next batch, recursively queue more if needed
add_action( 'fetch_import_batch', function( $import_id, $offset ) {
global $wpdb;
$job = $wpdb->get_row( $wpdb->prepare(
"SELECT * FROM wp_import_jobs WHERE import_id = %s",
$import_id
));
if ( ! $job || $job->status === 'failed' ) {
return; // Abort if job was marked failed
}
$batch = fetch_products_from_feed( $job->feed_url, $offset, 50 );
if ( empty( $batch ) ) {
// No more data
$wpdb->update(
'wp_import_jobs',
array( 'status' => 'completed' ),
array( 'import_id' => $import_id )
);
return;
}
// Queue next fetch
as_enqueue_async_action( 'fetch_import_batch', array( $import_id, $offset + 50 ) );
}, 10, 2 );
This pattern keeps your actions lightweight and your queue responsive. Each action completes quickly, and you track progress independently of the queue system.
Common mistakes
- Assuming synchronous execution: Action Scheduler is asynchronous. If you queue an action and immediately check a database value it should have set, it won't be there yet. Your code must not depend on immediate completion.
- Queueing one huge action: A single action processing 10,000 records will likely timeout. Batch aggressively—50 to 200 items per action is safer.
- Ignoring failed actions: Without monitoring the admin UI or logs, failed actions silently pile up. Build alerts for failed action counts.
- Non-idempotent handlers: If an action is retried, your handler must produce the same end state. Use database transactions and checks to prevent duplicates.
- Relying on WP-Cron as the runner: WP-Cron only triggers on traffic. In low-traffic sites, queued actions may sit for hours. Use loopback requests or custom runners in production.
Key takeaways
- Action Scheduler decouples long-running work from user requests by storing actions in the database and processing them asynchronously.
- Use
as_enqueue_async_action()for one-off tasks andas_schedule_recurring_action()for ongoing jobs. - Batch large operations into many small actions to avoid timeouts and improve resilience.
- Monitor the admin UI for pending, failed, and completed actions—it's your window into background job health.
- Write idempotent handlers that produce the same result even if retried multiple times.
- For production traffic, ensure reliable processing with loopback requests or custom runners rather than relying on WP-Cron.






Comments
Loading comments…