Background jobs with Action Scheduler

Queue large or delayed work as individual actions, process it in small batches, and inspect and run the queue from the admin and WP-CLI.

  • Intermediate
  • 10 min read
  • Updated 17 Oct 2026

What you will learn

  • Queue background actions
  • Process work in batches
  • Schedule recurring and one-off actions without duplicates
  • Inspect, run and debug the queue

Before you start

The WordPress course built a batched WP-Cron job by hand: keyset pagination, a lock in an option, each batch scheduling the next. That works, and it shows the idea. Action Scheduler is a free library that packages the same idea properly: every unit of work is stored as its own row with a status, failures are logged, and there is an admin screen to see what happened. This lesson uses it to send reminder emails the day before each event.

What Action Scheduler is

Action Scheduler is a job queue for WordPress, maintained as an open source library and bundled by many large plugins. It stores actions in its own database tables. An action is a hook name, arguments, a scheduled time, an optional group and a status: pending, in progress, complete, failed or cancelled.

A queue runner picks up due actions and fires their hooks with do_action(), exactly like WP-Cron does. The runner is started by WP-Cron, every minute, and can also be run from WP-CLI.

Hand-built WP-Cron batchesAction Scheduler
StorageAll events in one cron optionOne row per action, in its own tables
Visibilitywp cron event listTools, then Scheduled Actions, with search, status and logs
FailuresLost unless you log themExceptions mark the action failed and record the message
Many small jobsBloats the cron optionDesigned for thousands of queued actions
ConcurrencyYour own lockClaims actions so two runners do not take the same one
HistoryNoneCompleted actions kept for a period, then cleaned up

Use WP-Cron for a handful of simple recurring tasks. Reach for Action Scheduler when work is large, must not be lost, or is triggered by users in volume, such as one job per booking.

Installing the library

Action Scheduler ships as a folder you include in your plugin. Install it with Composer, which the Composer lesson covers in depth:

Shell
composer require woocommerce/action-scheduler

Or download a release and place it in myplugin/lib/action-scheduler/. Either way, load it from the main file, at file load rather than inside a hook:

PHP
// wp-content/plugins/myplugin/myplugin.php
require_once MYPLUGIN_PATH . 'vendor/woocommerce/action-scheduler/action-scheduler.php';

Several plugins on one site may bundle different versions. The library handles this itself: each copy registers its version, and only the newest one is loaded. That is why you require its main file directly instead of relying on a class autoloader.

The API functions are ready once WordPress reaches init. Schedule actions on init or later, never at file load.

The API you need

FunctionDoes
as_enqueue_async_action( $hook, $args, $group )Run as soon as possible
as_schedule_single_action( $timestamp, $hook, $args, $group )Run once at a time
as_schedule_recurring_action( $timestamp, $interval, $hook, $args, $group )Run every $interval seconds
as_schedule_cron_action( $timestamp, $schedule, $hook, $args, $group )Run on a cron expression such as 0 8 * * *
as_has_scheduled_action( $hook, $args, $group )Is a matching pending action queued?
as_unschedule_all_actions( $hook, $args, $group )Cancel matching pending actions

Timestamps are Unix timestamps, in UTC. The $group argument tags your actions so you can filter the admin screen and clean up by group; use the plugin's prefix. Current versions also accept a $unique argument after $group that refuses to queue an action if a matching one is already pending.

Designing the reminder job

Sending every reminder in one callback is the mistake to avoid: with 2,000 bookings for tomorrow, one failure halfway leaves you not knowing who was emailed. Split the job into three levels:

  1. A daily action finds tomorrow's events and queues one action per event.
  2. Each event action processes that event's bookings in batches of 50, then queues the next batch.
  3. Each batch is small enough to finish well within PHP's limits, and if it fails, only that batch is marked failed.

Scheduling the daily action

PHP
<?php
// wp-content/plugins/myplugin/includes/reminders.php
defined( 'ABSPATH' ) || exit;

add_action( 'init', 'myplugin_schedule_reminders' );

/**
 * Make sure the daily reminder action exists.
 */
function myplugin_schedule_reminders(): void {
    if ( ! function_exists( 'as_has_scheduled_action' ) ) {
        return;
    }

    if ( as_has_scheduled_action( 'myplugin_queue_reminders', array(), 'myplugin' ) ) {
        return;
    }

    $first = new DateTimeImmutable( 'tomorrow 08:00', wp_timezone() );

    as_schedule_recurring_action( $first->getTimestamp(), DAY_IN_SECONDS, 'myplugin_queue_reminders', array(), 'myplugin' );
}

Checking on init rather than only on activation means the action is recreated after updates, deployments or a manual cleanup. as_has_scheduled_action() is a cheap indexed query, but if you want to avoid even that on front-end requests, run the check on admin_init instead.

Queuing one action per event

PHP
<?php
// wp-content/plugins/myplugin/includes/reminders.php

add_action( 'myplugin_queue_reminders', 'myplugin_queue_reminders' );

function myplugin_queue_reminders(): void {
    $tomorrow = ( new DateTimeImmutable( 'tomorrow', wp_timezone() ) )->format( 'Y-m-d' );

    $event_ids = get_posts(
        array(
            'post_type'      => 'myplugin_event',
            'post_status'    => 'publish',
            'posts_per_page' => -1,
            'fields'         => 'ids',
            'meta_key'       => 'myplugin_event_date',
            'meta_value'     => $tomorrow,
        )
    );

    foreach ( $event_ids as $event_id ) {
        as_enqueue_async_action(
            'myplugin_send_event_reminders',
            array(
                'event_id' => (int) $event_id,
                'after_id' => 0,
            ),
            'myplugin'
        );
    }
}

Arguments are stored as JSON, and with associative keys the callback receives them as separate parameters in order. Keep arguments small: IDs and cursors, never whole objects or lists of thousands of IDs.

Processing a batch

PHP
<?php
// wp-content/plugins/myplugin/includes/reminders.php

add_action( 'myplugin_send_event_reminders', 'myplugin_send_event_reminders', 10, 2 );

/**
 * Email up to 50 confirmed bookings for an event, then queue the next batch.
 *
 * @param int $event_id Event post ID.
 * @param int $after_id Last booking ID already processed.
 */
function myplugin_send_event_reminders( int $event_id, int $after_id ): void {
    global $wpdb;

    $batch_size = 50;
    $event      = get_post( $event_id );

    if ( ! $event || 'publish' !== $event->post_status ) {
        return; // The event was cancelled or deleted since the job was queued.
    }

    $bookings = $wpdb->get_results(
        $wpdb->prepare(
            'SELECT id, user_id FROM %i WHERE event_id = %d AND status = %s AND id > %d ORDER BY id ASC LIMIT %d',
            myplugin_bookings_table(),
            $event_id,
            'confirmed',
            $after_id,
            $batch_size
        )
    );

    foreach ( $bookings as $booking ) {
        $user = get_userdata( (int) $booking->user_id );

        if ( ! $user ) {
            continue;
        }

        $sent = wp_mail(
            $user->user_email,
            /* translators: %s: event title. */
            sprintf( __( 'Reminder: %s is tomorrow', 'myplugin' ), get_the_title( $event ) ),
            sprintf(
                /* translators: 1: user's name, 2: event link. */
                __( "Hi %1\$s,\n\nSee you tomorrow. Event details: %2\$s", 'myplugin' ),
                $user->display_name,
                get_permalink( $event )
            )
        );

        if ( ! $sent ) {
            throw new RuntimeException( sprintf( 'Reminder email failed for booking %d.', (int) $booking->id ) );
        }
    }

    if ( count( $bookings ) === $batch_size ) {
        as_enqueue_async_action(
            'myplugin_send_event_reminders',
            array(
                'event_id' => $event_id,
                'after_id' => (int) end( $bookings )->id,
            ),
            'myplugin'
        );
    }
}

How it behaves:

  • Keyset pagination, id > %d, as in the WordPress course, so each batch starts exactly where the last one ended.
  • The callback re-checks state. The event might have been unpublished between queueing and running.
  • Throwing marks the action failed. Action Scheduler catches the exception, records the message in the action's log and moves on. The admin screen shows exactly which batch failed and why.
  • No lock needed. The runner claims each action before running it, so two runners never take the same batch.

A failed action is not retried automatically, and a batch that fails after sending 20 of its 50 emails would resend those 20 if you rerun it. For work that must happen exactly once, record progress per item, for example a reminded_at column on the bookings table, and skip items already done. The data migrations lesson shows how to add that column safely.

Inspecting and running the queue

In the admin, Tools, then Scheduled Actions lists every action with its status, arguments, group and log. Filter by the myplugin group to see only yours. Pending actions can be run or cancelled from there.

With WP-CLI:

Shell
wp action-scheduler run
wp action-scheduler run --group=myplugin
wp action-scheduler --help

run processes due actions in the CLI process, without a web server timeout. On production, the same advice as for WP-Cron applies: set DISABLE_WP_CRON and run the queue from the system crontab, so jobs do not depend on visitor traffic.

By default the runner works in short bursts, and completed actions are deleted after a retention period, a month by default. Both are adjustable with filters documented on actionscheduler.org.

Cleaning up

Cancel your actions when the plugin is deactivated, so the queue does not fill with actions whose hooks no longer exist:

PHP
<?php
// wp-content/plugins/myplugin/includes/install.php, inside myplugin_deactivate().
if ( function_exists( 'as_unschedule_all_actions' ) ) {
    as_unschedule_all_actions( '', array(), 'myplugin' );
}

Passing an empty hook with a group cancels every pending action in that group. The library's tables are shared with every other plugin that uses it, so never drop them in uninstall.php.

Summary

  • Action Scheduler stores each job as its own action with a status and log, and runs due actions through WP-Cron or WP-CLI.
  • Bundle it and require its main file at load time; the newest bundled copy on the site wins.
  • Schedule recurring work on init with an as_has_scheduled_action() check, using UTC timestamps and a prefixed group.
  • Fan out large jobs: one action per item or batch, small arguments, keyset pagination and a re-check of state.
  • Throw on failure to mark an action failed; failed actions are not retried, so make batches safe to rerun.
  • Inspect under Tools, then Scheduled Actions, run with wp action-scheduler run, and cancel your group on deactivation.

Further reading