/ Article — Background Processing

Action Scheduler: WordPress Background Jobs & Concurrency

Action Scheduler is the most reliable background job system available in WordPress. This article covers how it differs from WP-Cron, how as_schedule_single_action works, what action_scheduler_queue_runner_concurrent_batches controls, how the queue runner fires, and how Webhook Actions uses Action Scheduler to power reliable webhook delivery.

TL;DR

  • Action Scheduler stores jobs in a database table, not the transient-based WP-Cron option — jobs survive PHP crashes and are never lost on restart
  • action_scheduler_queue_runner_concurrent_batches controls parallel batch execution — default 5, tune based on server capacity
  • Webhook Actions has its own database queue for jobs; when Action Scheduler is available, WA uses it to trigger its queue runner — replacing WP-Cron with AS's more reliable loopback-based dispatch
/ Overview

What is Action Scheduler and how does it differ from WP-Cron?

Action Scheduler is a scalable, database-backed background job library for WordPress. It was originally developed by Automattic for WooCommerce Subscriptions, extracted into a standalone library, and is now bundled with WooCommerce core. It is used by WooCommerce, WooCommerce Payments, WooCommerce Subscriptions, and over 100 other plugins as the foundation for reliable background processing.

The core architectural difference from WP-Cron is storage. WP-Cron stores scheduled events in the _cron WordPress option as a serialized PHP array. This means all scheduled events are loaded into memory on every page load, updating a single option row creates write contention under concurrent traffic, and the data structure is not designed for per-job status tracking or queryability.

Action Scheduler stores every job in dedicated database tables: wp_actionscheduler_actions, wp_actionscheduler_logs, wp_actionscheduler_groups, and wp_actionscheduler_claims. Each job has its own row with a status column (pending, in-progress, complete, failed, canceled), a scheduled date, an attempt count, and a full execution log. This structure makes per-job tracking, concurrent processing, and failure recovery possible in ways WP-Cron cannot support.

Feature WP-Cron Action Scheduler
Storage options table (_cron) Dedicated DB tables
Triggers Page load (traffic-dependent) WP-Cron + loopback HTTP requests
Per-job status None pending / in-progress / complete / failed
Concurrent processing Single batch Configurable parallel batches
Failure tracking None Per-action log with error message
Admin visibility WP-CLI only wp-admin/admin.php?page=action-scheduler
Survives PHP crash Jobs may be lost DB row survives, claimed jobs time out and retry

For lightweight, infrequent tasks — a weekly digest email, a daily log rotation — WP-Cron with a real system cron entry is sufficient. Action Scheduler becomes the right choice when you need persistent job storage, per-job failure tracking, concurrent batch processing, or an admin UI for job inspection and management.

/ API

How does as_schedule_single_action work?

as_schedule_single_action() is the primary API function for scheduling a one-time background job. It inserts a row into wp_actionscheduler_actions and returns the action ID. The job fires once at the specified timestamp, then its status is updated to complete or failed.

Function signature
/** * @param int $timestamp Unix timestamp when the action should run * @param string $hook The WordPress action hook to fire * @param array $args Arguments to pass to the hook callback * @param string $group Optional group name for organizing and querying actions * @return int The action ID */ as_schedule_single_action( $timestamp, $hook, $args = [], $group = '' );
Scheduling a webhook delivery job
// Schedule a webhook delivery job to run immediately (time = now). $action_id = as_schedule_single_action( time(), // fire as soon as the next queue runner cycle 'my_plugin_deliver_webhook', [ 'endpoint' => 'https://hooks.example.com/order', 'payload' => wp_json_encode( $payload ), 'attempt' => 1, ], 'my-plugin-webhooks' // group for admin UI filtering ); // Register the callback that fires when the action runs. add_action( 'my_plugin_deliver_webhook', function( $endpoint, $payload, $attempt ) { $response = wp_remote_post( $endpoint, [ 'headers' => [ 'Content-Type' => 'application/json' ], 'body' => $payload, 'timeout' => 15, ] ); $code = wp_remote_retrieve_response_code( $response ); if ( is_wp_error( $response ) || $code >= 500 || $code === 429 ) { // Retry: schedule a new action at exponential backoff interval. if ( $attempt < 5 ) { $delay = 60 * ( 2 ** ( $attempt - 1 ) ); as_schedule_single_action( time() + $delay, 'my_plugin_deliver_webhook', [ 'endpoint' => $endpoint, 'payload' => $payload, 'attempt' => $attempt + 1 ], 'my-plugin-webhooks' ); } } }, 10, 3 );

The key design: each retry is a new as_schedule_single_action call, not a modification of the original action row. The original action row ends with status complete (the callback ran without throwing) even if the delivery failed — the retry logic is application-level, above the Action Scheduler abstraction. This is the pattern Webhook Actions uses internally.

Other scheduling functions follow the same signature pattern: as_schedule_recurring_action() for repeating jobs and as_enqueue_async_action() for jobs that should run as soon as possible on the next queue cycle.

/ Concurrency

What is action_scheduler_queue_runner_concurrent_batches?

action_scheduler_queue_runner_concurrent_batches is both a PHP constant and a filter that controls how many Action Scheduler batch processes can run simultaneously. The default value is 5.

Each "batch" is a separate PHP process initiated by Action Scheduler through a loopback HTTP request to wp-admin/admin-ajax.php. When the queue runner fires, it checks how many batches are currently in-progress and spawns additional batches up to the configured limit. Each batch claims a set of pending actions from the database, processes them in sequence, then exits.

This design means Action Scheduler can process multiple jobs simultaneously on servers where parallel PHP execution is available — which is true of virtually all WordPress hosting environments. A queue of 50 pending webhooks with 5 concurrent batches of 10 actions each can complete in roughly the time it takes to process a single batch of 10.

  Queue runner fires (WP-Cron or system cron)
       │
       ▼
  Check: how many batches currently in-progress?
       │
       ├─ Less than concurrent_batches limit
       │       └─ Spawn additional batch via loopback HTTP
       │
       └─ At limit: skip (another runner fires next minute)

  Each batch process:
       ├─ Claim N pending actions (lock via wp_actionscheduler_claims)
       ├─ Execute each action's hook callback
       ├─ Update status: complete / failed
       └─ Release claim

The claiming mechanism in wp_actionscheduler_claims prevents two concurrent batches from processing the same action. When a batch starts, it inserts a claim row for each action it intends to process. Other batches will not select actions that already have an active claim. If a batch crashes mid-execution, the claim row remains — a stuck-detection sweep resets these to pending after a configurable timeout.

/ Tuning

How do you control queue concurrency with the constant?

You can override the concurrent batch limit using either a PHP constant in wp-config.php or a WordPress filter. The constant takes precedence over the filter when both are present.

wp-config.php — set concurrency via constant
// Limit Action Scheduler to 3 concurrent batches. // Useful on shared hosting or memory-constrained environments. define( 'ACTION_SCHEDULER_QUEUE_RUNNER_CONCURRENT_BATCHES', 3 );
functions.php — set concurrency via filter
// Filter version — can be conditional (e.g. based on environment). add_filter( 'action_scheduler_queue_runner_concurrent_batches', function( $batches ) { return 3; } );

When to lower this value from the default of 5:

  • Shared hosting — PHP worker pool is limited, typically 4–8 processes total. Five concurrent AS batches can exhaust the entire worker pool and block front-end requests.
  • Memory-constrained VPS — each batch is a full WordPress bootstrap. At 128MB per process, five concurrent batches consume over 640MB of PHP memory before any plugin code runs.
  • Database contention — high-frequency loopback requests creating claims on a busy wp_actionscheduler_actions table can cause MySQL lock contention. Reducing concurrency reduces claim write frequency.

When to raise this value:

  • Dedicated VPS or cloud server — ample PHP workers and memory available; increasing to 10–20 batches dramatically increases webhook throughput under load.
  • WooCommerce high-volume sites — large order volumes during flash sales or promotions generate many simultaneous webhook jobs; higher concurrency reduces queue drain time.
/ Queue Runner

What does the queue runner do and when does it fire?

The Action Scheduler queue runner is the process that inspects the wp_actionscheduler_actions table for overdue pending actions and dispatches them. It fires in two ways: via WP-Cron (traffic-dependent, unreliable on low-traffic sites) and via loopback HTTP requests that Action Scheduler itself initiates.

The WP-Cron trigger runs on a one-minute schedule. On each execution, the runner checks whether any batch slots are available, then initiates loopback HTTP requests for up to action_scheduler_queue_runner_concurrent_batches additional batch workers. Each batch worker is a separate PHP process that runs independently of the original cron trigger.

This means even with WP-Cron as the sole trigger, Action Scheduler can sustain high throughput: the cron fires once per minute, spawns five parallel batch workers, and each worker processes a batch of actions. The loopback workers run to completion independently — they do not depend on the original cron process staying alive.

For production reliability, the same advice applies as for WP-Cron: add a real system cron entry that calls the cron endpoint every minute. This decouples Action Scheduler's trigger from page load traffic. Without a real system cron, a low-traffic site at 3am may not trigger the queue runner for hours, leaving queued webhook jobs unprocessed.

/etc/cron.d/wordpress — real system cron for Action Scheduler
# Fire WP-Cron every minute — triggers Action Scheduler queue runner * * * * * www-data curl -s https://yoursite.com/wp-cron.php?doing_wp_cron > /dev/null 2>&1

WP-CLI provides direct queue runner access for development and debugging. wp action-scheduler run runs the queue runner once synchronously, processing one batch of pending actions. This is useful for testing without waiting for the cron cycle.

/ Webhook Integration

How does Action Scheduler power webhook retry queues?

Action Scheduler's properties map directly to the requirements for reliable webhook delivery. Database-backed persistence means a queued delivery survives PHP crashes. Per-job status means you can inspect every delivery attempt. Concurrent batches mean a high-volume WooCommerce store can drain its webhook queue quickly. The admin UI means you can manually retry a failed delivery without touching code.

The typical pattern for webhook delivery on top of Action Scheduler:

  1. Capture: On the WordPress action hook (WooCommerce order complete, CF7 submission, etc.), serialize the payload and call as_schedule_single_action(time(), 'my_deliver_webhook', $args). The user-facing request ends here — no HTTP call made yet.
  2. Dispatch: The queue runner picks up the action. The callback calls wp_remote_post() and inspects the response code.
  3. Retry on transient failure: On 5xx or timeout, the callback schedules a new as_schedule_single_action with a calculated backoff delay and incremented attempt count.
  4. Permanent failure: After the maximum attempt count, no new action is scheduled. The failure is logged in the Action Scheduler log with the final status code and error message.
  WordPress action hook fires (order complete, form submit, etc.)
       │
       ▼
  Serialize payload
  as_schedule_single_action(time(), 'deliver_webhook', $args)
       │
       ▼
  User-facing request completes  ◄── no HTTP call made yet
       │
       (queue runner cycle, typically seconds later)
       ▼
  deliver_webhook callback runs
       │
       ├─ 2xx     → complete (logged)
       ├─ 5xx/429 → as_schedule_single_action(now + backoff, ...)
       └─ 4xx     → fail permanently (logged)

Webhook Actions applies a variation of this pattern: it maintains its own database queue for jobs rather than storing payloads in Action Scheduler action rows. What WA uses Action Scheduler for is triggering its queue runner — when AS is available, it registers an AS action to fire the queue runner on each cycle instead of relying on WP-Cron. The delivery log in the Webhook Actions admin panel is drawn from WA's own queue tables, not from the Action Scheduler log.

/ Monitoring

What are the failure modes and how do you monitor them?

Action Scheduler has four failure modes that are worth monitoring in production:

The Action Scheduler admin page at wp-admin/admin.php?page=action-scheduler provides visual status counts, filterable by status, hook, group, and date range. For automated monitoring, query the tables directly or use WP-CLI in a health-check script.

/ Webhook Actions

How does Webhook Actions integrate with Action Scheduler?

Webhook Actions maintains its own database-backed queue for outbound jobs — separate from the Action Scheduler tables. Every webhook trigger writes a job row to WA's queue rather than calling as_schedule_single_action(). The payload, attempt count, and retry state live in WA's own schema and are not stored in wp_actionscheduler_actions.

Where Action Scheduler comes in is queue triggering. WA checks at runtime whether Action Scheduler is available. If it is, WA registers an AS recurring action to fire its internal queue runner on each cycle. This replaces the WP-Cron trigger with AS's more reliable mechanism: loopback HTTP requests, concurrent batch dispatch, and AS's own stuck-action detection. If AS is not present, WA falls back to a WP-Cron scheduled event.

The practical result: on any site with WooCommerce (which ships AS), Webhook Actions automatically gets AS-quality trigger reliability without requiring you to configure anything. The delivery log in the Webhook Actions admin is sourced from WA's own queue tables, not from the Action Scheduler log — so wp action-scheduler list will show the recurring trigger action, not individual webhook delivery jobs.

Tuning ACTION_SCHEDULER_QUEUE_RUNNER_CONCURRENT_BATCHES affects how many concurrent AS batch workers fire WA's queue runner, which can increase the frequency of queue drain cycles on high-volume sites. The webhook delivery concurrency itself is controlled separately within WA's queue configuration.

/ FAQ

Common questions

action_scheduler_queue_runner_concurrent_batches is a constant (and a filter) that controls how many Action Scheduler batch processes can run simultaneously. The default value is 5 on most configurations. Each batch is a separate PHP process initiated by a loopback HTTP request. Setting this to a higher value increases throughput for large queues but also increases server memory usage. Set it too high on a shared host and you will exhaust the PHP worker pool.
as_schedule_single_action($timestamp, $hook, $args, $group) schedules a one-time action to fire at $timestamp. It inserts a row into wp_actionscheduler_actions with status pending. When the Action Scheduler queue runner next executes and finds a pending action whose scheduled time is in the past, it claims the action (status → in-progress), fires the hook, and marks it complete or failed. The function returns the action ID.
Action Scheduler ships bundled with WooCommerce — if WooCommerce is active, Action Scheduler is already installed. It is also available as a standalone Composer package (woocommerce/action-scheduler) for plugins that don't depend on WooCommerce. When multiple plugins bundle different versions of Action Scheduler, WordPress automatically uses the newest version present — the versioning system is designed for this.
Navigate to wp-admin/admin.php?page=action-scheduler. This admin page lists all actions with filters for status (pending, in-progress, complete, failed, canceled), hook name, group, and date range. You can manually run, cancel, or delete individual actions from this view. The page is available when WooCommerce is active — or when any plugin that bundles Action Scheduler registers it.
When a callback throws an uncaught exception, Action Scheduler marks the action as failed and records the error message in the log table. Failed actions are not automatically retried — you must explicitly reschedule them in your callback logic or use a retry-aware pattern.

Webhook Actions manages its own retry logic within its own queue: on a failed delivery, the job stays in WA's queue and is rescheduled at the next backoff interval internally. WA uses Action Scheduler only to trigger its queue runner — individual jobs are not stored as AS actions, so their retry state is tracked in WA's own tables rather than in Action Scheduler logs.