
What to remember
- WP-Cron only fires when someone loads a page. Quiet sites miss their schedules, and busy stores can run the same job several times at once.
- Action Scheduler keeps jobs in indexed tables, claims them in batches with row locks, and retries failures with growing delays, which lets a single site work through well over 100,000 background actions a day without the front end slowing down.
- Remote API syncs, big CSV exports and database housekeeping belong in a queue. Moving them out of the request keeps TTFB low and avoids 504 gateway timeouts, which matters for Core Web Vitals and for how much of your site Googlebot manages to crawl.
- The library can ship inside any standalone plugin through Composer, so you do not need WooCommerce to be active.
- Pair the queue with a real server-side runner (system crontab, a systemd service or WP-CLI) and jobs start on time instead of waiting for visitor traffic.
- Put a token bucket in front of third-party calls inside your workers, so bulk queues do not trigger HTTP 429 throttling.
Most slow WordPress builds we are asked to audit share one trait: the front-end request is doing work the visitor never asked to wait for. Every extra millisecond of Time-to-First-Byte (TTFB) feeds into Core Web Vitals, conversion rate and the crawl budget search engines assign to the site, so the cost of that habit shows up in both revenue and rankings.
The typical offenders are plugins that push customer data to a CRM, render invoice PDFs, purge large Redis cache trees or process bulk order webhooks directly inside hooks such as save_post, init or woocommerce_checkout_order_processed. The browser sits idle until PHP finishes. If the external service answers five seconds late, the shopper stares at a frozen checkout for five seconds.
The fix is architectural rather than cosmetic: take slow, failure-prone work off the request thread and run it from a queue. In WordPress, the proven tool for that is Action Scheduler, which turns your plugin into an event-driven system with background workers.
This guide covers how asynchronous work behaves in WordPress, why Action Scheduler beats WP-Cron for anything that matters, PSR-4 code you can adapt, retries and deadlock handling, token bucket rate limiting, server-level runners, Prometheus monitoring and queue table maintenance that does not need downtime.
Why Doing Everything Inside the Request Hurts
In classic procedural WordPress code, everything that follows an event happens one step after another inside a single PHP-FPM worker. Take a customer placing an order. The chain looks like this:
- The shopper presses “Place Order” and the POST request starts.
- Checkout fields are validated and taxes computed (50ms).
- The payment gateway captures the card (800ms).
- The order row is written to the database (30ms).
- The plugin posts the order data to an external ERP (1,200ms).
- The plugin renders a warranty certificate PDF for download (600ms).
- The plugin sends an onboarding email over SMTP (450ms).
- The response finally reaches the browser after 3,130ms in total.
That is more than three seconds of spinner for the buyer. It gets worse when the ERP is down: if it only gives up after 30 seconds, the whole checkout dies with a 504 Gateway Timeout although the card has already been charged.
In an event-driven design, the request does only what must happen before the customer sees a confirmation. Everything else becomes a job in the queue:
- The shopper presses “Place Order” and the POST request starts.
- Checkout fields are validated and taxes computed (50ms).
- The payment gateway captures the card (800ms).
- The order row is written to the database (30ms).
- Three jobs are placed in Action Scheduler:
sync_erp,generate_pdfandsend_email(2ms). - The response is back in the browser after 882ms in total, which is 72% faster.
- Separate queue workers then run those three jobs in the background, outside the visitor’s request.
For site owners the practical reading is simple: the faster the request returns, the better the real-user TTFB and INP numbers that Google collects, and the fewer abandoned checkouts. For agencies, it also means an outage at a third-party service stops being your client’s outage.
Where WP-Cron Breaks Down
WordPress ships with WP-Cron (wp-cron.php). It is fine for light housekeeping such as checking for core updates twice a day. Used as a job queue for volume or money-critical work, it has structural problems:
1. It Needs Visitors to Run
WP-Cron is not a daemon. On each front-end page view WordPress compares the clock with the next scheduled event, and if something is due it fires a non-blocking loopback HTTP request to wp-cron.php.
A staging site or a niche B2B portal may go hours or days between page views. Jobs that should run on time, such as billing renewals or abandoned cart reminders, simply wait in the database until the next visit.
2. Concurrent Runs and Duplicates
On a busy site, many visitors can trigger the loopback before the first process has written its new timestamp. The result is a race: one scheduled task, such as charging a subscription or syncing stock, can run 5 to 10 times in parallel.
3. Failures Leave No Trace
Every cron event lives in one serialized array in the cron row of wp_options. If a job hits a fatal error, runs out of memory or loses its database connection, it just vanishes: no log line, no stack trace, no retry. There is also no admin screen where you could see what is pending, running or broken.
4. Contention on One wp_options Row
Since the entire schedule is one serialized option, each enqueue, claim or delete reads and rewrites the whole thing. Under load this produces MySQL lock waits, cache invalidation storms and CPU spikes on the database server.
What Action Scheduler Does Differently
Action Scheduler was created by Prospress for WooCommerce Subscriptions and is now maintained as part of the WooCommerce project. It is a purpose-built job queue that drops the serialized-option approach in favour of dedicated, indexed tables:
wp_actionscheduler_actions: one row per job, holding its hook, arguments, run time, priority and status (pending, in-progress, complete, failed or canceled).wp_actionscheduler_logs: an append-only history of every attempt, with memory used, duration, error text and retry times.wp_actionscheduler_groups: named buckets (for examplewpstack-syncorwoocommerce-subscriptions) so batches from different features are processed separately.wp_actionscheduler_claims: atomic claim locks that stop two workers from picking up the same job.
Side by Side: WP-Cron and Action Scheduler
| Capability | Standard WP-Cron | Action Scheduler |
|---|---|---|
| Storage Architecture | Single serialized array in wp_options | Its own SQL tables, with foreign keys and composite indexes |
| Concurrency Safety | Can run the same task more than once at the same moment | Atomic MySQL row locking via wp_actionscheduler_claims |
| Execution Trigger | Front-end visitor HTTP loopback requests | WP-Cron, a WP-CLI runner or an always-on systemd daemon |
| Failure Recovery | A fatal PHP error silently drops the job | Retries with exponential backoff and a permanent error log |
| Admin Observability | None (requires third-party inspection plugins) | Built-in screen at Tools > Scheduled Actions, with search and a log viewer |
| Throughput Capacity | Fewer than 500 actions an hour before races appear | Over 100,000 actions an hour using concurrent WP-CLI runners |
| Job Grouping & Isolation | Flat single array (no namespacing) | Named groups, each with its own batch claim limit |
| Data Retention Control | Manual array filtering required | A configurable daily purge of finished records |
Building the Queue Into a Custom Plugin
Step 1: Pull In Action Scheduler With Composer
WooCommerce does not have to be installed. Action Scheduler can be vendored straight into a standalone plugin via Composer. Start with your plugin’s composer.json:
{
"name": "wpstack/event-driven-core",
"description": "High-throughput asynchronous event processing engine for WordPress.",
"type": "wordpress-plugin",
"license": "GPL-2.0-or-later",
"require": {
"php": ">=8.1",
"woocommerce/action-scheduler": "^3.8"
},
"autoload": {
"psr-4": {
"WPStack\Jobs\": "src/Jobs/",
"WPStack\Services\": "src/Services/",
"WPStack\Utilities\": "src/Utilities/"
}
},
"config": {
"platform": {
"php": "8.1"
},
"optimize-autoloader": true
}
}Run composer install --no-dev --optimize-autoloader to produce the vendor folder and an optimized classmap, then load Action Scheduler from your main plugin file:
<?php
/**
* Plugin Name: WPStack Event-Driven Core
* Description: Enterprise background queue processing engine powered by Action Scheduler.
* Version: 1.0.0
* Author: WPStack Studio
* Author URI: https://wpstack.online
* Text Domain: wpstack-core
* Requires PHP: 8.1
*/
declare(strict_types=1);
namespace WPStack;
if (!defined('ABSPATH')) {
exit;
}
// Load Composer Autoloader
if (file_exists(__DIR__ . '/vendor/autoload.php')) {
require_once __DIR__ . '/vendor/autoload.php';
}
// Initialize Action Scheduler early on plugins_loaded
add_action('plugins_loaded', function(): void {
if (!class_exists('ActionScheduler')) {
$action_scheduler_entry = __DIR__ . '/vendor/woocommerce/action-scheduler/action-scheduler.php';
if (file_exists($action_scheduler_entry)) {
require_once $action_scheduler_entry;
}
}
// Initialize custom worker service listeners
WPStackJobsQueueDispatcher::init();
WPStackJobsUserSyncWorker::init();
WPStackJobsDataExportWorker::init();
}, 0);Step 2: A Single QueueDispatcher Entry Point
Route every job through one QueueDispatcher class. It wraps Action Scheduler’s global functions (as_enqueue_async_action, as_schedule_single_action, as_schedule_recurring_action) so each job gets a consistent group name, a unique tracking hash and a priority flag, no matter which part of the plugin enqueues it:
<?php
declare(strict_types=1);
namespace WPStackJobs;
final class QueueDispatcher {
public const DEFAULT_GROUP = 'wpstack-event-engine';
public static function init(): void {
// Register custom queue groups and priority filters if needed
add_filter('action_scheduler_queue_runner_batch_size', [self::class, 'filter_batch_size']);
add_filter('action_scheduler_queue_runner_concurrent_batches', [self::class, 'filter_concurrent_batches']);
}
/**
* Dispatch an immediate asynchronous background action.
*
* @param string $hook The action hook name.
* @param array<string, mixed> $args Payload arguments passed to the worker.
* @param string $group Logical queue group.
* @param bool $unique Whether to prevent duplicate pending actions.
* @return int The enqueued Action ID.
*/
public static function dispatch_async(
string $hook,
array $args = [],
string $group = self::DEFAULT_GROUP,
bool $unique = true
): int {
if (!function_exists('as_enqueue_async_action')) {
error_log('[WPStack Queue] Action Scheduler is not loaded. Falling back to synchronous execution.');
do_action_ref_array($hook, $args);
return 0;
}
return (int) as_enqueue_async_action(
$hook,
$args,
$group,
$unique
);
}
/**
* Schedule an action to execute at a specific Unix timestamp.
*/
public static function dispatch_scheduled(
int $timestamp,
string $hook,
array $args = [],
string $group = self::DEFAULT_GROUP,
bool $unique = true
): int {
if (!function_exists('as_schedule_single_action')) {
return 0;
}
return (int) as_schedule_single_action(
$timestamp,
$hook,
$args,
$group,
$unique
);
}
/**
* Increase batch size for high-capacity servers.
*/
public static function filter_batch_size(int $batch_size): int {
return 100; // Default is 25
}
/**
* Increase concurrent runner batches for multi-core environments.
*/
public static function filter_concurrent_batches(int $concurrent): int {
return 5; // Default is 1
}
}Step 3: Workers That Fail Loudly and Safely
A worker class subscribes to the job’s hook, decodes the payload, validates it, runs the business logic and writes audit entries. When something transient goes wrong, for instance a third-party HTTP 503 or a socket timeout, the worker should catch the error, log useful detail and rethrow a runtime exception. That signals Action Scheduler to apply its retry logic instead of marking the job as done:
<?php
declare(strict_types=1);
namespace WPStackJobs;
use WPStackUtilitiesTokenBucketRateLimiter;
use Exception;
use RuntimeException;
final class UserSyncWorker {
public const HOOK_PROCESS_USER = 'wpstack_job_sync_user_to_crm';
public const HOOK_BATCH_CLEANUP = 'wpstack_job_batch_prune_transients';
public static function init(): void {
add_action(self::HOOK_PROCESS_USER, [self::class, 'handle_user_sync'], 10, 2);
add_action(self::HOOK_BATCH_CLEANUP, [self::class, 'handle_batch_cleanup'], 10, 1);
}
/**
* Process background synchronization of a WordPress user to external CRM.
*
* @param int $user_id
* @param string $trigger_source
* @throws RuntimeException If remote endpoint fails (triggers AS retry).
*/
public static function handle_user_sync(int $user_id, string $trigger_source = 'unknown'): void {
$user = get_userdata($user_id);
if (!$user) {
// User was deleted before queue processed; exit gracefully without retry
return;
}
// Apply Token Bucket Rate Limiting (Max 20 requests per second to external CRM API)
$limiter = new TokenBucketRateLimiter(
bucket_key: 'crm_api_rate_limit',
capacity: 20,
refill_rate_per_sec: 10
);
if (!$limiter->consume(1)) {
// Rate limit reached; delay execution by 15 seconds
QueueDispatcher::dispatch_scheduled(
timestamp: time() + 15,
hook: self::HOOK_PROCESS_USER,
args: ['user_id' => $user_id, 'trigger_source' => 'rate_limit_retry'],
unique: true
);
return;
}
// Prepare CRM payload
$payload = [
'external_id' => $user->ID,
'email' => $user->user_email,
'first_name' => $user->first_name,
'last_name' => $user->last_name,
'registered' => $user->user_registered,
'roles' => $user->roles,
'source' => $trigger_source,
];
$response = wp_remote_post('https://api.enterprise-crm.com/v1/contacts/sync', [
'timeout' => 15,
'headers' => [
'Content-Type' => 'application/json',
'Authorization' => 'Bearer ' . defined('CRM_API_SECRET') ? CRM_API_SECRET : '',
],
'body' => wp_json_encode($payload),
'data_format' => 'body',
]);
if (is_wp_error($response)) {
$error_message = $response->get_error_message();
throw new RuntimeException(sprintf('CRM Network Error for User #%d: %s', $user_id, $error_message));
}
$status_code = wp_remote_retrieve_response_code($response);
$body = wp_remote_retrieve_body($response);
if ($status_code >= 500 || $status_code === 429) {
// Server error or rate limited on remote side: throw exception to trigger exponential backoff retry
throw new RuntimeException(sprintf('CRM Server HTTP %d for User #%d: %s', $status_code, $user_id, $body));
}
if ($status_code >= 400) {
// Client error (e.g. 400 Bad Request, 422 Unprocessable): log permanent failure and do not retry
update_user_meta($user_id, '_wpstack_crm_sync_error', [
'timestamp' => time(),
'code' => $status_code,
'body' => substr($body, 0, 500),
]);
return;
}
// Success: store audit metadata
update_user_meta($user_id, '_wpstack_last_crm_sync', time());
delete_user_meta($user_id, '_wpstack_crm_sync_error');
}
/**
* Batch cleanup callback.
*/
public static function handle_batch_cleanup(string $prefix): void {
global $wpdb;
$sql = $wpdb->prepare(
"DELETE FROM {$wpdb->options} WHERE option_name LIKE %s AND option_name NOT LIKE '_transient_timeout_%'",
$wpdb->esc_like('_transient_' . $prefix) . '%'
);
$wpdb->query($sql);
}
}Step 4: Protect Downstream APIs With a Token Bucket
A worker that chews through hundreds of queued items quickly can flood a downstream service such as Salesforce, HubSpot, Stripe or OpenAI. Those services answer with HTTP 429 Too Many Requests, and your queue fills with retries that all fail for the same reason.
The limiter below is a token bucket that stores its state in the WordPress object cache (Redis or Memcached) and falls back to transients:
<?php
declare(strict_types=1);
namespace WPStackUtilities;
final class TokenBucketRateLimiter {
public function __construct(
private readonly string $bucket_key,
private readonly int $capacity = 20,
private readonly int $refill_rate_per_sec = 5
) {}
/**
* Attempt to consume $tokens from the bucket.
*
* @param int $tokens Number of tokens to consume.
* @return bool True if tokens were available and consumed, false if bucket is exhausted.
*/
public function consume(int $tokens = 1): bool {
$cache_key = 'wpstack_tb_' . $this->bucket_key;
$now = microtime(true);
$bucket = wp_cache_get($cache_key, 'rate_limits');
if (false === $bucket || !is_array($bucket)) {
$tokens_available = (float) $this->capacity;
$last_refill = $now;
} else {
$tokens_available = (float) $bucket['tokens'];
$last_refill = (float) $bucket['last_refill'];
}
// Calculate refilled tokens based on elapsed time
$elapsed = max(0.0, $now - $last_refill);
$tokens_available = min(
(float) $this->capacity,
$tokens_available + ($elapsed * $this->refill_rate_per_sec)
);
if ($tokens_available < $tokens) {
// Bucket empty: save current state and reject consumption
wp_cache_set($cache_key, [
'tokens' => $tokens_available,
'last_refill' => $now,
], 'rate_limits', 60);
return false;
}
// Deduct tokens and persist updated bucket
$tokens_available -= $tokens;
wp_cache_set($cache_key, [
'tokens' => $tokens_available,
'last_refill' => $now,
], 'rate_limits', 60);
return true;
}
}Running the Queue From the Server, Not the Browser
For dependable timing, switch off WordPress’s visitor-triggered cron and let a genuine system scheduler call WP-CLI to process the queue.
1. Turn Off the Built-In Cron in wp-config.php
// Disable front-end visitor-triggered cron execution
define('DISABLE_WP_CRON', true);
// Configure Action Scheduler queue batch size and timeout settings
define('ACTION_SCHEDULER_BATCH_SIZE', 100);
define('ACTION_SCHEDULER_TIMEOUT', 300);2. Add Frequent Runners to the System Crontab
Open the web server user’s crontab (for example crontab -e -u www-data) and register the runners:
# Standard WordPress cron runner (every 5 minutes)
*/5 * * * * /usr/local/bin/wp cron event run --due-now --path=/var/www/html --quiet > /dev/null 2>&1
# Dedicated Action Scheduler queue runner (runs every minute with batch size 100)
* * * * * /usr/local/bin/wp action-scheduler run --path=/var/www/html --batch-size=100 --hooks=wpstack_job_sync_user_to_crm,woocommerce_scheduled_subscription_payment --quiet > /dev/null 2>&13. A Long-Lived systemd Worker for Heavy Volume
Stores handling thousands of events a minute benefit from a dedicated systemd service that stays alive, so there is no process start-up delay and jobs begin within a second:
[Unit]
Description=WPStack Action Scheduler Queue Worker
After=network.target mysql.service
[Service]
Type=simple
User=www-data
Restart=always
RestartSec=5
ExecStart=/usr/local/bin/wp action-scheduler run --path=/var/www/html --batch-size=250 --force
[Install]
WantedBy=multi-user.targetKeeping Memory Flat in Long Batch Jobs
A single PHP process that walks through 10,000 or more records accumulates query logs and object cache entries. Once it crosses memory_limit, PHP is killed on the spot and the action stays marked in-progress.
Avoid that by clearing caches, freeing query logs and breaking circular references on a regular cadence inside the batch loop:
<?php
declare(strict_types=1);
namespace WPStackUtilities;
final class MemoryOptimizer {
/**
* Flush WordPress internal query log and in-memory caches.
*/
public static function flush_worker_memory(): void {
global $wpdb, $wp_object_cache;
// Disable query log to prevent unconstrained array growth
$wpdb->queries = [];
// Clear in-memory object cache if non-persistent
if (is_object($wp_object_cache)) {
$wp_object_cache->group_ops = [];
$wp_object_cache->stats = [];
$wp_object_cache->memcache_debug = [];
$wp_object_cache->cache = [];
}
// Force circular reference garbage collection
if (function_exists('gc_collect_cycles')) {
gc_enable();
gc_collect_cycles();
}
}
/**
* Check if memory consumption exceeds safe operational threshold.
*/
public static function is_memory_exhausted(float $threshold_percentage = 0.85): bool {
$memory_limit = ini_get('memory_limit');
if ('-1' === $memory_limit) {
return false;
}
$limit_bytes = self::convert_hr_to_bytes($memory_limit);
$current_bytes = memory_get_usage(true);
return ($current_bytes / $limit_bytes) >= $threshold_percentage;
}
private static function convert_hr_to_bytes(string $size): int {
$unit = strtolower(substr($size, -1));
$val = (int) substr($size, 0, -1);
return match ($unit) {
'g' => $val * 1024 * 1024 * 1024,
'm' => $val * 1024 * 1024,
'k' => $val * 1024,
default => (int) $size,
};
}
}Deadlocks, Retries and Backoff
With several workers running at once, two of them can end up waiting on each other’s rows and MySQL aborts one with 1213 Deadlock found when trying to get lock.
Action Scheduler copes with this through its own transaction handling. If a worker hits an unhandled exception or a deadlock, the transaction is rolled back, the action is flagged failed and a follow-up attempt is scheduled using an exponential backoff curve:
- Attempt 1: Immediate execution upon enqueue.
- Attempt 2 (first retry): runs 60 seconds later.
- Attempt 3 (second retry): runs 300 seconds (5 minutes) later.
- Attempt 4 (third retry): runs 1,800 seconds (30 minutes) later.
- Attempt 5 (last): runs 7,200 seconds (2 hours) later; if it still fails, the job is marked permanently failed and an alert is raised.
Watching Queue Health With Prometheus
A queue nobody monitors fails quietly. Send its health numbers to the same stack you already use for servers (Prometheus, Grafana, Datadog) so that a growing backlog or a jump in failures pages someone before customers notice, for example through PagerDuty.
The REST controller below exposes gauge metrics in Prometheus text format:
<?php
declare(strict_types=1);
namespace WPStackServices;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
final class MetricsEndpointController {
public static function init(): void {
add_action('rest_api_init', [self::class, 'register_routes']);
}
public static function register_routes(): void {
register_rest_route('wpstack/v1', '/queue-metrics', [
'methods' => WP_REST_Server::READABLE,
'callback' => [self::class, 'get_metrics'],
'permission_callback' => [self::class, 'check_metrics_permission'],
]);
}
public static function check_metrics_permission(WP_REST_Request $request): bool {
$auth_header = $request->get_header('Authorization');
if (!$auth_header || !defined('METRICS_API_KEY')) {
return false;
}
return hash_equals('Bearer ' . METRICS_API_KEY, $auth_header);
}
public static function get_metrics(): WP_REST_Response {
global $wpdb;
$actions_table = $wpdb->prefix . 'actionscheduler_actions';
$counts = $wpdb->get_results(
"SELECT status, COUNT(*) as total FROM {$actions_table} GROUP BY status",
ARRAY_A
);
$metrics_by_status = [
'pending' => 0,
'in-progress' => 0,
'complete' => 0,
'failed' => 0,
'canceled' => 0,
];
foreach ($counts as $row) {
$metrics_by_status[$row['status']] = (int) $row['total'];
}
// Render Prometheus plain-text exposition format
$output = "# HELP wpstack_queue_actions_total Total number of actions by status.
";
$output .= "# TYPE wpstack_queue_actions_total gauge
";
foreach ($metrics_by_status as $status => $val) {
$output .= sprintf("wpstack_queue_actions_total{status="%s"} %d
", $status, $val);
}
return new WP_REST_Response($output, 200, [
'Content-Type' => 'text/plain; version=0.0.4; charset=UTF-8',
]);
}
}Keeping the Queue Tables Small Without Downtime
A site that completes millions of actions per month will see wp_actionscheduler_actions and wp_actionscheduler_logs grow by tens of gigabytes. Unchecked, B-tree fragmentation sets in and the time to claim a batch drifts from about 5ms to more than 2,000ms, and that delay leaks straight into slower TTFB on any request that touches the queue.
Two reliable remedies are monthly MySQL range partitioning on the scheduled_date_gmt column, or a scheduled archive job that moves old rows out:
-- MySQL Range Partitioning for High-Volume Action Scheduler Tables
ALTER TABLE wp_actionscheduler_actions
PARTITION BY RANGE (UNIX_TIMESTAMP(scheduled_date_gmt)) (
PARTITION p_2026_08 VALUES LESS THAN (UNIX_TIMESTAMP('2026-09-01 00:00:00')),
PARTITION p_2026_09 VALUES LESS THAN (UNIX_TIMESTAMP('2026-10-01 00:00:00')),
PARTITION p_2026_10 VALUES LESS THAN (UNIX_TIMESTAMP('2026-11-01 00:00:00')),
PARTITION p_future VALUES LESS THAN MAXVALUE
);With partition pruning, MySQL ignores old partitions entirely while claiming pending actions, which cuts disk reads and buffer pool churn during traffic peaks.
Production Runbook: Symptoms and Fixes
| What you see | Likely cause | Fix and how to confirm it |
|---|---|---|
Actions stay in in-progress indefinitely | The PHP worker was killed by a fatal memory error or a hard SIGKILL from the server | Abandoned claims are released on their own after 5 minutes (action_scheduler_claim_timeout) |
| Nothing runs unless someone triggers it by hand | DISABLE_WP_CRON is on, but nobody added the matching entry to the server crontab | List the entries with crontab -l -u www-data and read the server execution logs |
| MySQL CPU spikes whenever the queue runs | Composite indexes are missing, or the table has grown beyond 2,000,000 finished action rows | Run wp action-scheduler clean to delete finished entries older than 30 days |
| Action Scheduler does not load at all | Several active plugins bundle clashing copies of the same Composer package | Load woocommerce/action-scheduler through proper namespace aliasing so only one copy is used |
| HTTP 429 Too Many Requests appears in the logs | The worker calls a third-party API faster than its rate limit allows | Turn on the token bucket limiter and reschedule rejected calls with a delay |
Testing Background Jobs With PHPUnit
Async code needs a different testing mindset. In a unit test there is no daemon to wait for. Instead, assert that the right action is enqueued with the right arguments, and call the worker callback directly to check its behaviour:
namespace WPStackTests;
use WP_UnitTestCase;
use WPStackJobsQueueDispatcher;
use WPStackJobsUserSyncWorker;
class QueueTest extends WP_UnitTestCase {
public function test_user_sync_action_enqueues_successfully(): void {
$user_id = $this->factory->user->create(['role' => 'customer']);
// Trigger action enqueue
$action_id = QueueDispatcher::dispatch_async(
UserSyncWorker::HOOK_PROCESS_USER,
['user_id' => $user_id]
);
$this->assertGreaterThan(0, $action_id);
// Verify action exists in Action Scheduler queue
$action = ActionScheduler::store()->fetch_action($action_id);
$this->assertEquals(UserSyncWorker::HOOK_PROCESS_USER, $action->get_hook());
$this->assertEquals(['user_id' => $user_id], $action->get_args());
}
public function test_worker_handles_sync_callback_without_errors(): void {
$user_id = $this->factory->user->create(['user_email' => 'test@wpstack.online']);
// Mock remote HTTP request to CRM
add_filter('pre_http_request', function($preempt, $parsed_args, $url) {
if (strpos($url, 'enterprise-crm.com') !== false) {
return [
'response' => ['code' => 200, 'message' => 'OK'],
'body' => json_encode(['status' => 'success']),
];
}
return $preempt;
}, 10, 3);
// Execute callback directly
UserSyncWorker::handle_user_sync($user_id, 'test_suite');
// Verify user meta was updated
$last_sync = get_user_meta($user_id, '_wpstack_last_crm_sync', true);
$this->assertNotEmpty($last_sync);
}
}Next Steps for Your Own Site or Client Build
A dependable queue touches database schema, worker concurrency, API limits and recovery paths, so it pays to design it deliberately instead of bolting jobs onto hooks one at a time. A good order of work is: list every task that currently runs inside a page request, move the slow and failure-prone ones into the queue, switch to a server-side runner, and add monitoring before launch.
MoxSEO’s web development team builds and audits custom WordPress and WooCommerce plugins and integrations, including background processing and performance work. If you want a second pair of eyes on an existing codebase or help planning a queue-based architecture, see our WordPress development services.
Common questions
Does Action Scheduler work on a WordPress Multisite network?
Yes. Every subsite keeps its own queue tables (`wp_X_actionscheduler_actions`), so each tenant’s jobs stay separate and one site’s work can never leak into another’s.
How are completed actions and old logs removed?
A built-in daily routine (`action_scheduler_cleanup_actions`) deletes completed and canceled actions after 30 days. If you need a longer or shorter window, change it with the `action_scheduler_retention_period` filter.
When should I use `as_enqueue_async_action` and when `as_schedule_single_action`?
`as_enqueue_async_action()` queues a job to run as soon as a worker is free (timestamp 0). `as_schedule_single_action($timestamp)` takes a future Unix timestamp, so the job waits until that moment.
What stops a long-running worker from leaking memory?
When run through WP-CLI or cron, Action Scheduler checks memory use on every batch iteration. Once usage passes 90% of `memory_limit`, it ends the current process cleanly and the next cron cycle starts a fresh PHP worker.
Can I use it together with a Redis object cache?
Yes. Claim locks are read straight from the SQL tables, which avoids stale object-cache reads, while metadata that rarely changes can be cached in Redis to speed up reads.
Where do I see running or failed actions in wp-admin?
Open Tools > Scheduled Actions (or WooCommerce > Status > Scheduled Actions). The screen lists pending, in-progress, completed and failed actions, with search, filters and a button to run a job manually.
What if PHP hits its execution time limit in the middle of a batch?
Action Scheduler enforces its own limit (`action_scheduler_maximum_execution_time`). It tracks elapsed time, finishes the batch before PHP’s max execution limit is reached and leaves the remaining actions for the next cycle.
How can I delete old completed actions with WP-CLI?
Run `wp action-scheduler clean –batch-size=1000 –status=complete` over SSH or from server cron. Working in batches clears millions of old log rows without locking tables that live customer transactions depend on.
Aditya Bhimrajka is the Chief Search Systems Architect at MoxSEO, leading research in enterprise technical SEO, knowledge graphs, large-scale indexing physics, and generative engine optimization.





