Async at Scale

Async and Background Processing With Action Scheduler

⏱ 17 min

Every lesson so far in this course has assumed an AI call happens somewhere inside a request, an ability’s execute_callback, a save_post handler, a page render, and the user waits for it. That’s fine for a call that returns in under a second. It’s a real problem for anything slower: generating a long-form draft, processing a batch of products, running a multi-step analysis. Keeping a request open while a model provider takes ten or twenty seconds to respond ties up a PHP worker, risks a web server timeout, and gives the visitor nothing to look at but a spinner. Action Scheduler, the real, independently maintained library behind WooCommerce’s background processing, moves that work out of the request entirely: you enqueue a job, the request returns immediately, and the AI call runs afterward, on its own schedule, with retry and concurrency handling you didn’t have to write.

What you'll learn in this lesson
Action Scheduler vs raw WP-Cron
Why persisted, logged jobs with retry are a meaningfully different guarantee than a scheduled hook.
as_enqueue_async_action() for run-immediately background jobs
The right call for "start this now, just not in this request."
as_schedule_single_action() for run-later jobs
The right call for "run this at a specific future time."
Moving a slow AI call out of save_post entirely
A worked example: queueing an AI summary job instead of generating inline.
Where to actually see what ran
Action Scheduler's own admin list of pending, running, complete, and failed actions.
Prerequisites

General familiarity with WordPress hooks (add_action()), and the caching and invalidation patterns from Lessons 1 and 2, since the worked example in this lesson writes its result into the same cache this course already built.

Step 1: Action Scheduler is not just WP-Cron with a nicer name

Raw WP-Cron schedules a callback to run on a future page load, with no persistence, no retry, and no record of whether it actually ran or failed. Action Scheduler, the library WooCommerce and many other plugins rely on for background processing, solves that by storing every scheduled job in the database as a real, queryable record: what it is, when it’s due, whether it ran, and whether it succeeded. That persistence is exactly what a slow AI call needs, if a job fails partway through, you have a durable record of it to inspect and act on, rather than a callback that silently never ran.

Persisted jobs
Stored in the database, not just scheduled in memory for the next cron tick.
Retry and logging built in
A failed action leaves a record, rather than disappearing.
Concurrency control
Jobs are processed by a queue runner designed not to pile up unboundedly under load.

Step 2: Two real entry points, run-now versus run-later

Action Scheduler's core scheduling calls
1
as_enqueue_async_action( $hook, $args, $group, $unique, $priority )
Queues a job to run as soon as the next queue-processing cycle picks it up, effectively "now, but not in this request." This is the one you want for moving a slow AI call off the critical path.
2
as_schedule_single_action( $timestamp, $hook, $args, $group, $unique, $priority )
Schedules a job for a specific future Unix timestamp, for work that genuinely needs to happen later, not just outside this request.

Both calls schedule a WordPress action hook, exactly like do_action() would, just deferred. You still need your own add_action() for that hook, Action Scheduler handles the timing and persistence, not the logic:

// File: my-plugin/class-async-summarizer.php
add_action( 'my_plugin_generate_post_summary_async', 'my_plugin_run_post_summary_job', 10, 1 );

function my_plugin_run_post_summary_job( int $post_id ): void {
	$post = get_post( $post_id );

	if ( ! $post ) {
		return;
	}

	$prompt = 'Summarize in two sentences: ' . $post->post_title . "\n\n" . $post->post_content;
	$system = 'You are a concise editorial summarizer.';

	$result = wp_ai_client_prompt( $prompt )
		->using_system_instruction( $system )
		->generate_text();

	if ( is_wp_error( $result ) ) {
		error_log( 'AI summary job failed for post ' . $post_id . ': ' . $result->get_error_message() );
		return;
	}

	update_post_meta( $post_id, '_ai_summary', $result );
}

Step 3: Queue the job instead of generating inline on save_post

This is the actual production shift: instead of calling generate_text() directly inside a save_post handler and making the editor wait on the model provider before their save even finishes, queue the job and let the request return immediately:

// File: my-plugin.php
add_action( 'save_post', function ( int $post_id, WP_Post $post ) {
	if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) ) {
		return;
	}

	if ( 'publish' !== $post->post_status ) {
		return;
	}

	as_enqueue_async_action(
		'my_plugin_generate_post_summary_async',
		array( $post_id ),
		'my-plugin-ai'
	);
}, 10, 2 );

The editor’s save completes the moment save_post finishes, with no wait on the model provider at all. The summary appears in post meta shortly after, generated by the queued job, not the request that triggered it.

Use as_schedule_single_action() for genuinely delayed work

If the job should run at a specific later time rather than as soon as possible, digesting a day’s comments once at midnight, for instance, use as_schedule_single_action( strtotime( 'tomorrow midnight' ), 'my_plugin_daily_digest', array(), 'my-plugin-ai' ) instead. The $group argument ('my-plugin-ai' in both examples) keeps your plugin’s jobs identifiable and separable from every other plugin’s scheduled actions on a busy site.

Step 4: Where to actually see this running

Action Scheduler ships with its own admin list of scheduled actions, showing pending, in-progress, complete, and failed jobs, reachable under WooCommerce’s Status screen on sites running WooCommerce, or via Action Scheduler’s own admin screen when it’s loaded standalone by another plugin. That list is the real place to confirm a queued AI job actually ran, rather than adding your own separate tracking UI just to answer that question.

A queued job still needs something to process the queue

Action Scheduler processes its queue on WordPress’s normal request lifecycle (similar to how WP-Cron itself depends on site traffic to fire, unless you’ve configured a real system cron trigger). On a very low-traffic site, queued actions can sit longer than expected before they’re picked up. For anything time-sensitive, make sure the site has a reliable way for scheduled hooks to actually fire, the same operational concern that applies to WP-Cron generally.

Test it

Save a published post and confirm the summary appears in post meta shortly after, without the save request itself waiting on it:

wp-env run cli wp eval '
$post_id = wp_insert_post( array(
	"post_title"   => "Async Summary Test",
	"post_content" => "This is a test post used to confirm background AI summarization.",
	"post_status"  => "publish",
) );
echo "Post saved instantly, ID: " . $post_id . "\n";
'

Then, after giving the queue a moment to process, check that same post ID (substitute the ID printed above):

wp-env run cli wp eval 'echo get_post_meta( 123, "_ai_summary", true );'

You should see the generated summary, produced by the queued job rather than the wp_insert_post() call itself.

Recap

Action Scheduler turns “schedule this for later” into a persisted, retryable, queryable job rather than a hopeful WP-Cron callback. as_enqueue_async_action() moves a slow AI call out of the request that triggered it, running it as soon as the queue picks it up; as_schedule_single_action() handles work that genuinely needs to wait until a specific future time. Either way, you still register a normal add_action() for the hook Action Scheduler fires, the library handles timing and persistence, your code handles the actual AI call.

Resources & further reading

← Model Routing for Cost Rate Limiting AI Traffic at Scale →