TL;DR: action_scheduler_run_queue is the WP-Cron hook that starts every Action Scheduler queue pass.
- Action Scheduler registers it as a recurring event on its own
every_minuteschedule (a 60-second interval it adds itself). - When the hook fires,
ActionScheduler_QueueRunner::run()executes with the context stringWP Cron. - One pass claims batches of 25 actions, one after another, until nothing is due or about 30 seconds have passed. A separate async loopback runner, started from admin-side requests, adds more passes.
- The hook being scheduled is not the same as the hook firing — if WP-Cron never runs, neither does the queue.
/ Definition
What is action_scheduler_run_queue?
It is the name of the WordPress cron hook that Action Scheduler schedules for itself, and the entry point for all queue processing. Nothing in Action Scheduler runs on a timer of its own: the library leans on WP-Cron for its heartbeat, and action_scheduler_run_queue is the hook that heartbeat calls.
That makes it the single most useful symbol to know when a queue is stuck. If scheduled actions are piling up as pending past their due time, the question is almost always "is action_scheduler_run_queue firing?" — not "is the action broken?".
/ Registration
How is the hook registered and scheduled?
In ActionScheduler_QueueRunner, as two class constants and an init() method that wires them up. The hook name is fixed in the source; the schedule defaults to every_minute but passes through the action_scheduler_run_schedule filter:
PHP — ActionScheduler_QueueRunner (simplified)
const WP_CRON_HOOK = 'action_scheduler_run_queue'; const WP_CRON_SCHEDULE = 'every_minute'; public function init() { // Add the custom 60-second interval to WP-Cron's schedules. add_filter( 'cron_schedules', [ $this, 'add_wp_cron_schedule' ] ); $cron_context = [ 'WP Cron' ]; if ( ! wp_next_scheduled( self::WP_CRON_HOOK, $cron_context ) ) { $schedule = apply_filters( 'action_scheduler_run_schedule', self::WP_CRON_SCHEDULE ); wp_schedule_event( time(), $schedule, self::WP_CRON_HOOK, $cron_context ); } add_action( self::WP_CRON_HOOK, [ self::instance(), 'run' ] ); }
Three things are worth pulling out. The every_minute schedule is not a WordPress built-in — Action Scheduler adds it through the cron_schedules filter with an interval of 60 seconds. The event is registered through wp_schedule_event() with an argument array of ['WP Cron'], which is why a raw look at the cron array shows the hook carrying one argument. And the callback is the queue runner's run() method, not a standalone function.
That argument array matters more than it looks. Because wp_next_scheduled() matches on hook name and arguments, an event scheduled with different args is a different event. That is also why init() first unschedules any argument-less copy of the hook left behind by Action Scheduler versions before 3.0. The argument itself is the $context string covered below, and that string — logged as "action started via WP Cron" — is how a cron-driven pass is told apart from a manual or loopback one in the logs.
/ Dispatch
What happens when the hook fires?
ActionScheduler_QueueRunner::run() executes with the context it was scheduled with, and attempts one queue pass. A pass is a loop of batches, not a single batch:
PHP — the callback signature
public function run( $context = 'WP Cron' ) { // 1. Bail if the concurrent-batch limit is already reached. // 2. Claim a batch of due actions (default 25). // 3. Execute each action, then release the claim. // 4. Repeat 2-3 until no actions are due, the time limit // (default 30s) is near, or 90% of memory is used. }
The $context string is purely descriptive — it identifies what triggered this pass, and shows up in the logs written to wp_actionscheduler_logs. You will see WP Cron for the scheduled event, Async Request for a loopback pass, and WP CLI when a pass is driven from the command line. It does not change the work performed.
Step 1 is the one that surprises people. Before doing anything, the runner checks whether another batch is already in flight, and exits immediately if the concurrency limit is hit. By default that limit is one, so on a site where a long-running action is still executing, the next minute's tick does nothing at all. That is deliberate — it protects the database from parallel claim contention — and it is tunable, as covered in the concurrent batches filter reference.
Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards.
/ Limits
Which filters change what a pass does?
Four, and their defaults are documented in the Action Scheduler performance guide:
| Filter | Default | What it controls |
|---|---|---|
action_scheduler_queue_runner_batch_size | 25 | How many actions a single claim locks in one batch. |
action_scheduler_queue_runner_concurrent_batches | 1 | How many batches may run at the same time across all runners. |
action_scheduler_queue_runner_time_limit | 30 | Seconds one pass may spend executing before it stops claiming more work. |
action_scheduler_run_queue | — | Not a filter — the cron hook itself. Fire it with do_action() to force a pass. |
The time limit is why raising the batch size alone rarely helps: a pass that runs out of its 30 seconds stops mid-batch regardless of how many actions it claimed. Throughput on a backed-up queue comes from more passes or more concurrency, not bigger batches. The rows those batches lock live in the claims table — see the Action Scheduler database schema for how a claim_id is minted and released.
Because this action is only triggered at most once every minute, then there will rarely be more than one queue processing actions even if the concurrent runners are increased. — Action Scheduler performance guide
/ Loopback
How does the async loopback runner fit in?
It is a second source of queue passes, separate from cron. A cron tick gives you one pass of up to about 30 seconds per minute — the rest of each minute the queue sits idle. The async runner fills that gap. It is not started by the cron pass: at the shutdown of an admin-side request (at most once every 60 seconds), Action Scheduler sends a non-blocking loopback request to admin-ajax.php. That request fires action_scheduler_run_queue with the context Async Request, sleeps 5 seconds, and dispatches the next loopback for as long as actions are due and the concurrency limit allows it.
The practical consequence: loopback requests are HTTP requests your site makes to itself. If they are blocked — by HTTP authentication, a firewall rule, a misconfigured host, or a plugin that intercepts loopbacks — the queue silently falls back to the cron rate: one pass of up to about 30 seconds per minute. Note that the chain only starts from admin-side requests (where is_admin() is true), so a site where nobody opens wp-admin can rely on cron alone even when loopbacks work.
/ Troubleshooting
Why is the hook scheduled but nothing runs?
Because a scheduled WP-Cron event is only a promise. WP-Cron is triggered by traffic: on each page load WordPress checks whether anything is due and spawns a request to run it. No traffic, no tick — and no queue pass. Three failure modes cover almost every case:
DISABLE_WP_CRONis true with no replacement. Defining the constant switches off the page-load trigger. If nothing callswp-cron.phpon a schedule after that,action_scheduler_run_queueis scheduled forever and fired never. The fix is a real system cron entry, covered in the WP-Cron developer reference; the warnings this produces are decoded in DISABLE_WP_CRON is set to true.- Loopback requests are blocked. The queue still moves, but only during one pass of up to about 30 seconds per minute. Check whether the site can make HTTP requests to itself.
- A long-running action holds the only concurrency slot. Each minute's tick sees the limit reached and exits. The queue looks frozen while one action is in fact still executing.
/ Manual runs
How do you trigger a queue pass by hand?
Three ways, in increasing order of control. In PHP, fire the hook directly — this runs a pass synchronously in the current request:
PHP — force one queue pass
do_action( 'action_scheduler_run_queue', 'Manual' );
From the command line, Action Scheduler ships its own WP-CLI command, which is the right tool for clearing a backlog because it is not bound by the web request time limit and, with --force, bypasses the concurrency guard:
Shell — WP-CLI
# Run batches until the queue is empty (--batches=0 means "keep going"). wp action-scheduler run --batches=0 # Larger batches, only one hook, ignoring the concurrency limit. wp action-scheduler run --batch-size=200 --hooks=my_plugin_sync_order --force
Note that the WP-CLI command defaults to a batch size of 100, not the 25 used by the web queue runner — it assumes it is running without a web request's constraints. And --force overrides the concurrency limit, which is exactly what you want for a one-off drain and exactly what you do not want on a cron schedule.
For the functions that put actions into the queue in the first place, see the Action Scheduler PHP API reference.