WordPress plugin: cron & background jobs
SkillProductivityDesigns and reviews scheduled/background work in WordPress plugins: wp_schedule_event, wp_schedule_single_event, cron_schedules, wp_next_scheduled guards, activation scheduling, deactivation cleanup, WP-Cron pseudo-cron timing, DISABLE_WP_CRON/system cron, multisite per-blog cron, idempotent callbacks, chunking, and Action Scheduler graduation. Use when adding scheduled jobs, debugging late/duplicate cron events, or deciding between WP cron and Action Scheduler.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the WordPress plugin: cron & background jobs skill
What this skill tells your AI
The instructions your AI receives, as published by lonsdale201/wp-agent-skills in plugin-scaffold/wp-plugin-cron/SKILL.md and read by ahel’s review.
Scheduled work — daily cleanup, periodic API sync, deferred email send, retry on failed webhook delivery — is a normal part of plugin life. WordPress ships its own cron primitive (wp_schedule_event), and the WooCommerce-bundled Action Scheduler library extends the model into a proper queue. Picking between them and using the chosen one correctly is what this skill covers.
When to use this skill
Trigger when ANY of the following is true:
- Scheduling a periodic task (daily option cleanup, hourly token refresh, weekly digest email).
- Deferring work off the request thread (e.g. send email after the form submission returns).
- Debugging "my cron event was registered but never fires" or "fires multiple times" or "fires hours late".
- Deciding between native WP cron and Action Scheduler for a non-trivial background workload.
- Reviewing a plugin's activation / deactivation hooks for cron schedule + clear correctness.
Mental model — WP cron is pseudo-cron, not real cron
WordPress cron does NOT run on a system schedule. There is no cron daemon waking WP up. Instead:
- Page request comes in.
- On
init, WordPress callswp_cron()(wp-includes/default-filters.php). - Since WP 6.9,
wp_cron()registers_wp_cron()onshutdownfor normal requests, so the cron spawn does not hurt TTFB as much. WithALTERNATE_WP_CRON, it still useswp_loaded. _wp_cron()checks for due events and makes a non-blocking loopback request to/wp-cron.php, which actually runs the due events.
In WordPress 7.1, the cron spawn passes its resolved cron URL as the second
argument to https_local_ssl_verify. Existing one-argument callbacks continue
to work, while a filter that needs host-specific local TLS policy can register
for two arguments. Keep verification exceptions exact and development/host
specific; do not disable TLS verification globally for cron.
Implications:
- No traffic = no cron. A site with 5 visitors/day fires cron events 5 times/day, max. A "daily" event on a low-traffic site might run every 3 days.
- Late firing is normal. An "hourly" event scheduled at noon may fire at 12:47 if that's when the next visitor lands.
- Concurrent visitors can race. WP has internal locking but it's best-effort; on a busy site, two requests may both attempt to spawn the same event before the lock takes effect.
- Long-running events block the loopback. If your
daily_cleanupcallback runs 90 seconds, the wp-cron.php request takes 90 seconds. Other due events in that batch wait.
For real-time precision OR predictable timing, set DISABLE_WP_CRON in wp-config.php and configure system cron to call wp-cron.php every minute:
define( 'DISABLE_WP_CRON', true );
* * * * * curl -s https://example.com/wp-cron.php > /dev/null 2>&1
This is host-level setup, NOT the plugin's responsibility — but the plugin's docs should mention it for users with timing-sensitive workloads.
Native WP cron — the basic API
Register custom intervals before scheduling, guard recurring events with the exact hook+args through wp_next_scheduled(), check WP_Error results for important jobs, wire callbacks on every runtime request, and clear owned hooks on deactivation. Use wp_schedule_single_event() for one-shot deferred work.
Read references/native-wp-cron-patterns.md for the complete activation/runtime/deactivation example, error handling, argument-sensitive deduplication, and one-shot scheduling.
Multisite — cron is per-blog
Each site in a multisite network has its own scheduled events. wp_schedule_event writes to the current blog's cron option; wp_unschedule_hook reads from the current blog only. Treat cron as a per-site primitive.
For a network-wide periodic task, two options:
- Schedule per site at activation (network activation iterates sites, see
wp-plugin-lifecycle):foreach ( get_sites( array( 'fields' => 'ids' ) ) as $site_id ) { switch_to_blog( $site_id ); if ( ! wp_next_scheduled( 'myplugin_daily_cleanup' ) ) { wp_schedule_event( time() + DAY_IN_SECONDS, 'daily', 'myplugin_daily_cleanup' ); } restore_current_blog(); } - Schedule once on the main blog if the work is genuinely site-wide (writes to network options, not per-blog data). Document the choice — future maintainers will assume per-site otherwise.
Multisite cron caveat: this skill's authoring environment is single-site. The above is source-derived from wp-includes/cron.php but not end-to-end tested in a real network. Verify before relying.
Action Scheduler — when WP cron is not enough
Action Scheduler is a queue-style background-job library bundled with WooCommerce (and standalone available via Composer). It uses its own DB tables instead of wp_options and adds capabilities WP cron doesn't have.
When to graduate from WP cron to Action Scheduler:
| Need | WP cron | Action Scheduler |
|---|---|---|
| Scheduling 5-10 plugins' worth of events | OK | OK |
| 10,000+ scheduled actions (e.g. one per order) | slow / breaks — cron option grows huge in wp_options, all autoloaded | designed for it |
| Per-action status tracking (pending / running / completed / failed) | none | built-in |
| Retry after callback failure | manual | explicit; failed one-off actions are not auto-retried |
| Duplicate guards | partial (single-event 10-min de-dup by hook+args) | AS 4.0 $unique for exact hook+group+encoded-args identities; exact-args checks for mixed older runtimes |
| Admin UI to inspect queue / re-run failures | none | yes (Tools → Scheduled Actions) |
| Logical grouping of related actions | none | group parameter |
| Graceful concurrency (multiple workers) | no | yes |
Detection in code:
if ( function_exists( 'as_schedule_recurring_action' ) ) {
$supports_unique = ( new ReflectionFunction( 'as_schedule_recurring_action' ) )->getNumberOfParameters() >= 6;
if ( $supports_unique ) {
as_schedule_recurring_action(
time() + DAY_IN_SECONDS, // first run
DAY_IN_SECONDS, // interval
'myplugin_daily_cleanup',
array(), // args
'myplugin', // group (logical bucket)
true // unique in modern Action Scheduler
);
} elseif ( ! as_next_scheduled_action( 'myplugin_daily_cleanup', array(), 'myplugin' ) ) {
as_schedule_recurring_action(
time() + DAY_IN_SECONDS,
DAY_IN_SECONDS,
'myplugin_daily_cleanup',
array(),
'myplugin'
);
}
} else {
// Fall back to native WP cron.
if ( ! wp_next_scheduled( 'myplugin_daily_cleanup' ) ) {
wp_schedule_event( time() + DAY_IN_SECONDS, 'daily', 'myplugin_daily_cleanup' );
}
}
The corresponding clear:
if ( function_exists( 'as_unschedule_all_actions' ) ) {
as_unschedule_all_actions( 'myplugin_daily_cleanup', array(), 'myplugin' );
} else {
wp_unschedule_hook( 'myplugin_daily_cleanup' );
}
For one-shot deferred work, the parallel pair is as_schedule_single_action() / wp_schedule_single_event(). Action Scheduler 4.0's DBStore makes $unique argument-aware, so canonical per-entity args can be part of the queue identity. Action Scheduler 3.x did not include args; integrations supporting unknown older active copies should use exact-args guards. Every version still needs idempotent callbacks.
The cost of Action Scheduler: it's a hard dependency. For a small plugin with 1-2 daily events on a low-traffic site, native WP cron is fine. Don't pull in WooCommerce or vendor Action Scheduler for a single hourly cleanup.
Long-running work and idempotency
Cron callbacks run inline. A 60-second daily_cleanup ties up the whole cron batch for that minute. Two patterns to avoid blocking:
- Chunk and re-schedule: process N rows, then
wp_schedule_single_event( time() + 1, 'myplugin_daily_cleanup' )if more remain. - Defer per-item to single events:
wp_schedule_single_event( time(), 'myplugin_process_item', array( $id ) )per row. Parallelizes well with Action Scheduler; overkill for native WP cron.
Even with wp_next_scheduled guards at schedule time, the callback must be idempotent — manual triggers (spawn_cron), restored backups, WP-CLI wp cron event run, and manually or explicitly scheduled Action Scheduler retries can all replay events. Two minimal patterns:
// Gate by data state — preferred when the work is per-entity.
add_action( 'myplugin_send_invoice', static function ( int $order_id ): void {
if ( get_post_meta( $order_id, '_invoice_sent', true ) ) return;
myplugin_send( $order_id );
update_post_meta( $order_id, '_invoice_sent', time() );
} );
// Soft lock + last-success gate — for global periodic jobs.
add_action( 'myplugin_daily_cleanup', static function (): void {
$last = (int) get_option( 'myplugin_cleanup_last_success', 0 );
if ( time() - $last < HOUR_IN_SECONDS ) return;
if ( get_transient( 'myplugin_cleanup_lock' ) ) return;
set_transient( 'myplugin_cleanup_lock', 1, 15 * MINUTE_IN_SECONDS );
try {
myplugin_run_cleanup();
update_option( 'myplugin_cleanup_last_success', time(), false );
} finally {
delete_transient( 'myplugin_cleanup_lock' );
}
} );
The transient lock and get_option+update_option pattern is non-atomic (TOCTOU race) but good enough for soft idempotency. Action Scheduler 4.0's $unique support can suppress an exact queue identity, but for per-entity hard idempotency use a unique-key insert into an owned table or a provider-enforced idempotency key as the gate.
Critical rules
- WP cron is pseudo-cron. WordPress calls
wp_cron()oninit; since WP 6.9 the normal spawn runs onshutdown(wp_loadedforALTERNATE_WP_CRON). No traffic = no cron. UseDISABLE_WP_CRON+ system cron for timing-critical work. - Always guard schedule with
wp_next_scheduledto prevent duplicate events on reactivation. - Check scheduling failures for custom intervals or important jobs by passing
$wp_error=true. - Always pair schedule (activation) with clear (deactivation) using
wp_unschedule_hook(since WP 4.9, hook+args agnostic). - Custom intervals via
cron_schedulesfilter — return an array withinterval(seconds) +display(label). - Cron is per-blog in multisite — schedule per-site if the work is per-site.
- Make callbacks idempotent. Data-state check, soft lock, or
last_successtimestamp gate. - Long-running work goes in chunks (re-schedule a single event after a batch) — don't block the worker.
- Graduate to Action Scheduler when you need queue semantics: 10k+ actions, status tracking, explicit retry workflows, duplicate guards, admin UI. Don't pull it in for one-off uses.
Common mistakes
// WRONG — duplicate events on every reactivation
register_activation_hook( __FILE__, function () {
wp_schedule_event( time(), 'daily', 'myplugin_cleanup' );
} );
// WRONG — args mismatch, deactivation fails to clear the event
register_activation_hook( __FILE__, function () {
wp_schedule_event( time(), 'daily', 'myplugin_cleanup', array( 'mode' => 'fast' ) );
} );
register_deactivation_hook( __FILE__, function () {
wp_clear_scheduled_hook( 'myplugin_cleanup' ); // missing args
} );
// Use wp_unschedule_hook instead.
// WRONG — assumes cron fires at the registered time
wp_schedule_event( time() + 60, 'hourly', 'myplugin_send_invoices_at_4pm' );
// On a low-traffic site this might run at 5:13 PM, 6:48 PM, 9:02 PM...
// WRONG — non-idempotent callback fires twice on retry
add_action( 'myplugin_charge_card', function ( $order_id ) {
stripe_charge( $order_id ); // bills user twice on retry
} );
// RIGHT — gate by data state
add_action( 'myplugin_charge_card', function ( $order_id ) {
if ( get_post_meta( $order_id, '_charged', true ) ) return;
stripe_charge( $order_id );
update_post_meta( $order_id, '_charged', time() );
} );
// WRONG — registering 10,000 actions in wp-cron
foreach ( $orders as $order ) {
wp_schedule_single_event( time(), 'myplugin_process', array( $order->id ) );
}
// 10k events bloats the autoloaded 'cron' option; site grinds.
// Use Action Scheduler for this scale.
Cross-references
- Run
wp-plugin-lifecyclefor the activation-schedule / deactivation-clear pattern in full lifecycle context — including multisite-aware$network_widecallback args. - Run
wp-plugin-options-storagefor the warning about the autoloadedcronoption — at scale (10k+ events) it becomes the autoload bottleneck. - Run
wp-security-auditon cron callbacks — they run with no current user, so capability checks based on a "logged in user" don't work. Treat persisted args / IDs as untrusted input. - Run
wp-action-scheduleronce the design graduates to Action Scheduler — this skill only covers the decision point and minimal fallback pattern.
What this skill does NOT cover
- Action Scheduler internal architecture, queue tables, runner process, WP-CLI commands, and 4.0 API details — covered by
wp-action-scheduler. - WP-CLI cron commands (
wp cron event list,wp cron event run,wp cron schedule list) — adjacent topic, useful for debugging but separate skill scope. - External queue systems (Redis Queue, AWS SQS, Beanstalkd) integrated into WP — viable for ultra-high-throughput plugins but out of WP-native scope.
- Server-side cron daemon configuration.
References
- WP Cron Handbook: developer.wordpress.org/plugins/cron/
wp_schedule_event/wp_schedule_single_event/wp_next_scheduled/wp_unschedule_hook:wp-includes/cron.phpcron_schedulesfilter: developer.wordpress.org/reference/hooks/cron_schedules/- WP 6.9 cron change (
_wp_cronmoved toshutdown):wp-includes/cron.phpwp_cron()docblock - WP 7.1 cron TLS filter context:
wp-includes/cron.phpspawn_cron() - Action Scheduler: actionscheduler.org, bundled in WooCommerce
- Official documentation: https://developer.wordpress.org/reference/functions/wp_schedule_event/
- Official documentation: https://developer.wordpress.org/reference/functions/wp_schedule_single_event/
Signals
- GitHub stars
- 22
- Forks
- 2
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
wp-plugin-cron- Source
- github.com/lonsdale201/wp-agent-skills