Back to Top

WordPress Action Scheduler Limits with High-Volume Stores

Updated 2 July 2026

If you run a busy WooCommerce store, a lot of what keeps it alive happens quietly in the background. Order emails, renewals, inventory syncs, and webhooks never run on the page the customer sees.

The engine behind all of that is Action Scheduler. It works beautifully at first. But on high-volume stores, it slowly becomes one of the hardest-to-spot bottlenecks in the whole stack.

When that queue backs up, the cost is real: late renewals, missed webhooks, and stale stock that leads to oversells.

This guide shows why Action Scheduler becomes a bottleneck, how to spot it, and the fixes that actually move the needle.

Who Should Read This Guide

It is written for two readers.

If you own the store, you’ll learn the warning signs and the no-code steps you can take today.

If you’re the engineer, you’ll get the queries, snippets, and server changes to fix it for good.

What Action Scheduler Is — and Why Your Store Depends on It

Action Scheduler is a scalable, traceable job queue library that ships inside WooCommerce.

It powers nearly every deferred operation in your store — order emails, inventory syncs, subscription renewals, and webhook deliveries.

Anything scheduled with as_schedule_single_action() or as_schedule_recurring_action() runs through it too.

Under the hood, it is three parts working together:

  • A persistent queue backed by custom database tables.
  • A scheduler that maps each action to a future timestamp.
  • A runner that claims and executes those actions during request cycles.

It uses four custom tables: actionscheduler_actions, actionscheduler_logs, actionscheduler_groups, and actionscheduler_claims.

On a high-volume store, the actions table alone can grow into millions of rows. That growth is where most of the trouble starts.

Cron lock contention for action schedular

The Life of a Single Action

Every job moves through a small set of statuses. Once you know them, the rest of this guide reads much easier.

  • pending — scheduled and waiting for its time to arrive.
  • in-progress — claimed by a runner and executing right now.
  • complete — finished successfully.
  • failed — threw an error or timed out, and may be retried.
  • canceled — unscheduled before it ever ran.

A healthy store moves actions from pending to complete quickly. A bottleneck is simply pending piling up faster than your runners can clear it.

How the Queue Runs — and Why WP-Cron Holds It Back

By default, Action Scheduler piggybacks on WP-Cron. And WP-Cron is not a real cron job — it fires on page load.

That single design choice creates three recurring problems on busy stores.

  • Triggered by traffic, not time. WP-Cron only fires when a visitor hits the site. During quiet hours, scheduled actions pile up silently.
  • Conservative defaults. Each runner claims a batch of up to 25 actions and keeps processing batches for at most 30 seconds before it stops.
  • Contention under concurrency. Many concurrent page requests can each try to start a runner. The claim mechanism stops double-execution, but the database work to coordinate those claims still adds load.

A common misconception is that a cron run processes only one batch.

It doesn’t. The runner loops, claiming batch after batch until it hits the time limit, the memory limit, or runs out of due actions.

Here is a simplified version of that loop, so you can see where the cost lives:

// Simplified from ActionScheduler_QueueRunner — illustrative, not verbatim
public function run( $context = 'WP Cron' ) {
    $processed = 0;

    // Bail early if too many batches are already running concurrently
    if ( $this->has_maximum_concurrent_batches() ) {
        return $processed;
    }

    // Keep claiming and running batches until a limit is hit
    do {
        $in_batch   = $this->do_batch( $this->get_batch_size(), $context );
        $processed += $in_batch;
    } while ( $in_batch > 0 && ! $this->batch_limits_exceeded( $processed ) );

    return $processed;
}

// One batch: claim due actions atomically, then run them
protected function do_batch( $size = 25, $context = '' ) {
    $claim = $this->store->stake_claim( $size );

    foreach ( $claim->get_actions() as $action_id ) {
        $this->process_action( $action_id, $context );
    }

    $this->store->release_claim( $claim );
    return count( $claim->get_actions() );
}

The expensive step is stake_claim().

It runs an UPDATE ... LIMIT that stamps a unique claim_id onto a batch of due, unclaimed rows.

On a healthy table, that update is cheap.

On a bloated table with degraded indexes, it scans far more rows than it claims — blocking other runners that wait their turn.

Why Action Scheduler Becomes a Bottleneck at Scale

High-volume stores rarely hit just one limit.

They hit several at once, and the symptoms compound.

In ecommerce terms, a slow queue is not just a tech problem.

A late renewal is lost revenue, a dropped webhook breaks fulfillment, and a stalled sync causes oversells.

You likely have a queue problem if you recognize any of these signs:

  • Order or shipping emails arrive minutes — or hours — late.
  • Subscription renewals process well after their due time.
  • The admin “Scheduled Actions” screen is slow or times out.
  • The pending count keeps climbing and never settles back down.

Three forces drive that degradation. Here is how each one works.

1. The Queue Fills Faster Than It Drains

Every extension that uses Action Scheduler adds to the same queue.

On a store doing 500+ orders a day with subscriptions, memberships, and sync plugins, tens of thousands of pending actions is normal.

A batch of 25 simply cannot drain that fast enough.

2. The Tables Bloat and Slow Every Query

By default, Action Scheduler keeps completed and failed actions for around 30 days before cleanup.

At high volume, the actions and logs tables swell into the millions of rows.

Every query that touches them — including the admin “Scheduled Actions” screen — gets slower.

3. Actions Get Stuck Mid-Run

Slow or memory-leaking callbacks don’t always fail cleanly.

They can exhaust PHP’s memory limit mid-batch and take the whole runner down with them.

When that happens, the actions already claimed in that batch stay marked in-progress with their claim still attached.

Action Scheduler does try to recover on its own.

Its cleaner resets actions stuck past action_scheduler_timeout_period (default 300 seconds), and marks repeat offenders failed after action_scheduler_failure_period.

But recovery only runs when a runner runs.

On a stalled queue, those resets are delayed too — and that gap is where renewals fire late, webhooks drop, and stock updates stall.

What Store Owners Can Do Right Now

You don’t have to be a developer to act on this.

Most of the relief comes from three decisions you can make today.

1. Move the Site to a Real Cron Job

Ask your host to disable WP-Cron and run cron on a fixed schedule instead.

Most managed WooCommerce hosts offer this as a setting or a quick support request.

This removes the “no traffic, no processing” gap and is the single highest-impact change you can make.

2. Install the Official High-Volume Helper

WooCommerce maintains a free, open-source plugin — Action Scheduler – High Volume — that safely raises batch size, concurrency, and time limits.

Have your developer or host install it once.

There are no settings to configure and no custom code for you to maintain.

3. Watch the Queue From Your Dashboard

Open WooCommerce → Status → Scheduled Actions. The Pending tab shows everything waiting to run.

Check it weekly. A queue that drains back down after busy periods is healthy.

A Pending count that only ever grows is your warning sign.

If pending stays high after these steps, hand this article to a developer and continue with the diagnostics and fixes below.

How to Diagnose a Slow Action Scheduler Queue

Before you change anything, get hard numbers.

These queries and snippets give you a clear read on queue health.

One note first: the SQL below assumes the default wp_ table prefix. Swap it for your own if you changed it at install time.

1. Measure Queue Depth and the Oldest Pending Action

-- Current state of the action queue, grouped by status and hook
SELECT
    status,
    hook,
    COUNT(*) AS total,
    MIN(scheduled_date_gmt) AS oldest,
    MAX(scheduled_date_gmt) AS newest
FROM wp_actionscheduler_actions
GROUP BY status, hook
ORDER BY total DESC;

-- Check the claim query's plan.
-- If "type" in the EXPLAIN output shows "ALL", it's doing a full table scan.
EXPLAIN SELECT action_id
FROM wp_actionscheduler_actions
WHERE claim_id = 0
  AND status = 'pending'
  AND scheduled_date_gmt <= UTC_TIMESTAMP()
ORDER BY scheduled_date_gmt ASC
LIMIT 25;

2. Queue Lag Report in PHP

This logs counts by status and the lag of the oldest pending action.

Lag is the real health signal — a deep queue that still drains on time is fine.

Treat this as a temporary diagnostic. It runs count queries on every request.

Capture your baseline, then remove it — don’t leave per-request queries running on a busy store.

<?php
/**
 * Logs queue health on shutdown.
 * Add to: /wp-content/mu-plugins/as-diagnostics.php
 */
function store_queue_health_report() {
    if ( ! class_exists( 'ActionScheduler' ) ) {
        return;
    }

    global $wpdb;
    $store    = ActionScheduler::store();
    $statuses = [ 'pending', 'in-progress', 'failed', 'complete' ];

    // query_actions() returns a count when 'count' is passed as the query type
    foreach ( $statuses as $status ) {
        $count = $store->query_actions( [ 'status' => $status ], 'count' );
        error_log( "AS Health [{$status}]: {$count} actions" );
    }

    // Lag = now minus the oldest pending scheduled time
    $oldest = $wpdb->get_var(
        "SELECT MIN(scheduled_date_gmt)
         FROM {$wpdb->prefix}actionscheduler_actions
         WHERE status = 'pending'"
    );

    if ( $oldest ) {
        $lag_sec = time() - strtotime( $oldest . ' UTC' );
        error_log( "AS Health: oldest pending lag = {$lag_sec}s" );
    }
}
add_action( 'shutdown', 'store_queue_health_report' );

3. Find and Reset Stuck In-Progress Actions

Use this only as a manual fallback when the built-in cleaner can’t run because the queue itself is stalled.

-- Actions stuck "in-progress" for over 10 minutes are suspect
SELECT
    a.action_id,
    a.hook,
    a.status,
    a.last_attempt_gmt,
    TIMESTAMPDIFF(MINUTE, a.last_attempt_gmt, UTC_TIMESTAMP()) AS minutes_stuck,
    a.args
FROM wp_actionscheduler_actions a
WHERE a.status = 'in-progress'
  AND a.last_attempt_gmt < DATE_SUB(UTC_TIMESTAMP(), INTERVAL 10 MINUTE)
ORDER BY minutes_stuck DESC
LIMIT 50;

-- Reset them to pending and drop the claim so a runner can retry
UPDATE wp_actionscheduler_actions
SET    status   = 'pending',
       claim_id = 0
WHERE  status   = 'in-progress'
  AND  last_attempt_gmt < DATE_SUB(UTC_TIMESTAMP(), INTERVAL 10 MINUTE);

Developer Fixes That Restore Throughput

The rest of this guide is the engineer’s track.

Each fix is a small snippet you can drop into an mu-plugin, plus the server and database changes that back it up.

Fix 1 — Increase Batch Size and Concurrency

Action Scheduler exposes filters for batch size, concurrency, and the time limit.

The defaults are deliberately safe, not fast.

The official action-scheduler-high-volume plugin sets sensible values: batch size ×4, concurrent batches ×2, and a 120-second time limit.

The snippet below mirrors that.

<?php
/**
 * Scale up Action Scheduler throughput.
 * Add to: /wp-content/mu-plugins/as-tuning.php
 *
 * Rule of thumb: batch_size x avg_action_ms < (time_limit x 0.8 x 1000)
 */

// Actions per batch (default: 25)
add_filter( 'action_scheduler_queue_runner_batch_size', function() {
    return 100;
});

// Max concurrent batches (default: 5). Raising this increases server load —
// scale it to your CPU and DB headroom, and watch load after each change.
add_filter( 'action_scheduler_queue_runner_concurrent_batches', function() {
    return 10;
});

// Time limit per runner in seconds (default: 30).
// Keep PHP max_execution_time comfortably above this value.
add_filter( 'action_scheduler_queue_runner_time_limit', function() {
    return 120;
});

Note that concurrency does not require system cron.

Action Scheduler can fan out work through its async (loopback HTTP) runner even on WP-Cron — system cron just makes it predictable.

Fix 2 — Reduce Log Retention and Purge in Chunks

<?php
/**
 * Trim retention from ~30 days to 7 days.
 * Add to: /wp-content/mu-plugins/as-cleanup.php
 */
add_filter( 'action_scheduler_retention_period', function() {
    return WEEK_IN_SECONDS;
});

/**
 * One-time bulk purge of old finished actions.
 * Run via WP-CLI or a custom admin trigger — NOT on every request.
 * Chunked deletes keep table locks short.
 */
function purge_old_as_actions() {
    global $wpdb;
    $cutoff = gmdate( 'Y-m-d H:i:s', strtotime( '-7 days' ) );

    do {
        $deleted = $wpdb->query( $wpdb->prepare(
            "DELETE FROM {$wpdb->prefix}actionscheduler_actions
             WHERE status IN ('complete','failed','canceled')
               AND scheduled_date_gmt < %s
             LIMIT 500",
            $cutoff
        ));
    } while ( $deleted === 500 );
}

For routine cleanup, prefer the built-in WP-CLI clean command shown later — it removes orphaned logs too, not just the action rows.

Fix 3 — Deduplicate Before Scheduling

A big source of bloat is code that schedules an action without checking whether one is already pending.

WooCommerce core guards against this — many third-party plugins do not.

<?php
/**
 * Safe single-action scheduler — avoids duplicate pending actions.
 *
 * @param string $hook  Action hook name.
 * @param array  $args  Arguments passed to the action.
 * @param int    $delay Seconds from now to run.
 * @return int|false    Action ID, or false if one is already scheduled.
 */
function schedule_once( $hook, $args = [], $delay = 0 ) {
    if ( as_has_scheduled_action( $hook, $args ) ) {
        return false; // Already pending — skip
    }
    return as_schedule_single_action(
        time() + $delay,
        $hook,
        $args,
        'my-plugin-group'
    );
}

// Usage: a single, de-duplicated inventory sync per order
add_action( 'woocommerce_order_status_processing', function( $order_id ) {
    schedule_once(
        'sync_inventory_for_order',
        [ 'order_id' => $order_id ],
        30
    );
});

Fix 4 — Keep the Indexes Healthy

Past a few million rows, MySQL’s planner can misjudge the table and fall back to a full scan on the claim query.

Refresh statistics and confirm the composite claim index is present.

Run these with wp db query < fix-indexes.sql.

-- Refresh planner statistics
ANALYZE TABLE wp_actionscheduler_actions;

-- Rebuild the table to defragment indexes (online on MySQL 5.7+ / MariaDB 10.3+)
ALTER TABLE wp_actionscheduler_actions ENGINE=InnoDB;

-- Action Scheduler ships this composite index for the claim query.
-- Confirm it exists:
SHOW INDEX FROM wp_actionscheduler_actions
WHERE Key_name = 'claim_id_status_scheduled_date_gmt';

-- If it is somehow missing, recreate it
CREATE INDEX claim_id_status_scheduled_date_gmt
    ON wp_actionscheduler_actions (claim_id, status, scheduled_date_gmt);

Architectural Upgrades for High-Volume Stores

Replace WP-Cron with a Real System Cron

This is the single highest-impact change.

Stop firing cron on page loads and run it on a fixed schedule instead.

Step 1 — Disable WP-Cron’s HTTP trigger in wp-config.php:

define( 'DISABLE_WP_CRON', true );

Step 2 — Add a real cron entry with crontab -e:

# Run all due WP-Cron events every minute
* * * * * /usr/local/bin/wp --path=/var/www/html cron event run --due-now --quiet >> /var/log/wpcron.log 2>&1

# Drive the Action Scheduler queue directly for higher throughput.
# --force overrides the concurrent-batch limit, so keep this to a single
# entry to avoid overlapping runs piling onto the database.
* * * * * /usr/local/bin/wp --path=/var/www/html action-scheduler run --force >> /var/log/as-runner.log 2>&1

WP-CLI Commands for Queue Management

Running the queue from the command line avoids the constraints of a normal web request, which makes it the better tool for large queues.

# Overall queue status (counts by status)
wp action-scheduler status

# Run the queue now — useful after a cron gap. --force ignores concurrency limits.
wp action-scheduler run --force

# Clean up old actions. Defaults to 'complete' and 'canceled' older than ~31 days.
wp action-scheduler clean --batch-size=200 --pause=1

# Target a specific status and age, in chunks, pausing between batches
wp action-scheduler clean --status=failed --batch-size=100 --before='30 days ago' --pause=2

There is no top-level cancel or delete command — pruning is done through clean.

To cancel specific actions, use as_unschedule_all_actions( $hook ) in code.

Monitor the Queue So It Never Surprises You Again

Reactive debugging is expensive.

The monitor below schedules itself and alerts Slack when a threshold is crossed — before customers notice.

<?php
/**
 * Self-scheduling queue health monitor.
 * Runs every 5 minutes via Action Scheduler.
 * Add to: /wp-content/mu-plugins/as-monitor.php
 */

add_action( 'init', function() {
    if ( ! function_exists( 'as_has_scheduled_action' ) ) {
        return;
    }
    if ( ! as_has_scheduled_action( 'monitor_as_queue_health' ) ) {
        as_schedule_recurring_action(
            time(),
            5 * MINUTE_IN_SECONDS,
            'monitor_as_queue_health',
            [],
            'monitoring'
        );
    }
});

add_action( 'monitor_as_queue_health', function() {
    $store      = ActionScheduler::store();
    $thresholds = [
        'pending_max'     => 10000,
        'in_progress_max' => 50,
        'failed_max'      => 100,
    ];

    // Pass 'count' as the query type to get totals, not row data
    $pending     = $store->query_actions( [ 'status' => 'pending' ],     'count' );
    $in_progress = $store->query_actions( [ 'status' => 'in-progress' ], 'count' );
    $failed      = $store->query_actions( [ 'status' => 'failed' ],      'count' );

    $alerts = [];

    if ( $pending > $thresholds['pending_max'] ) {
        $alerts[] = "Pending queue: {$pending} actions";
    }
    if ( $in_progress > $thresholds['in_progress_max'] ) {
        $alerts[] = "Stuck in-progress: {$in_progress} actions";
    }
    if ( $failed > $thresholds['failed_max'] ) {
        $alerts[] = "Failed actions: {$failed}";
    }

    if ( ! empty( $alerts ) ) {
        $webhook = get_option( 'slack_webhook_url' );
        if ( ! $webhook ) {
            return;
        }
        wp_remote_post( $webhook, [
            'body'    => wp_json_encode( [
                'text' => '*Action Scheduler Alert* | ' . get_bloginfo( 'name' )
                          . "\n" . implode( "\n", $alerts ),
            ] ),
            'headers' => [ 'Content-Type' => 'application/json' ],
        ]);
    }
});

Where to Start — Impact vs. Effort

You don’t need to apply everything at once.

Work top to bottom — the high-impact, low-effort wins come first.

OptimizationExpected ImpactEffort
Real system cronHigh — removes traffic-driven timing gapsLow
Increase batch sizeHigh — roughly 4x throughput per runLow
Reduce log retentionHigh — large table-size reductionLow
Index maintenanceMedium — restores claim-query efficiencyMedium
Deduplicate schedulingMedium — stops re-accumulationMedium
Concurrent runnersHigh — near-linear scaling, watch server loadMedium

Conclusion

Action Scheduler is solid engineering, but it was designed for moderate workloads rather than stores processing thousands of orders daily. Its biggest risk is that performance issues develop gradually instead of causing obvious failures.

As delays accumulate, renewals can run late and pending actions can grow rapidly. Fixing the bottleneck requires a layered approach with queue optimization, database maintenance, and ongoing monitoring.

. . .

Leave a Comment

Your email address will not be published. Required fields are marked*


Be the first to comment.

Back to Top

Message Sent!

If you have more details or questions, you can reply to the received confirmation email.

Back to Home