/ Article — Reliability

Cron Job for WordPress: WP-Cron Limits and Real Fixes

WP-Cron is not a real scheduler. It is a polite fiction that works fine until traffic dips, a server restarts, or a queue starts growing faster than it is drained. This article explains how WordPress scheduled tasks actually work, why they fail silently on low-traffic sites, how to replace WP-Cron with a real system cron job, when Action Scheduler is the right tool, and how cron reliability feeds directly into webhook delivery.

/ How It Works

WP-Cron is a pseudo-scheduler, not a daemon

A real cron job is a system-level daemon — crond on Linux — that wakes up on a fixed schedule, independent of application traffic. WordPress has no equivalent. What it has is WP-Cron: a PHP-based scheduling system that runs inside a normal HTTP request, only when someone visits the site.

The mechanics are straightforward. Every time WordPress bootstraps — on every non-AJAX, non-admin page load — it calls spawn_cron(). That function checks whether any scheduled events are due by reading the cron option from wp_options. If events are overdue, WordPress fires a non-blocking HTTP request to /wp-cron.php in the background and continues serving the page. The cron request runs in a separate PHP process, executes due events, and exits.

Scheduled events are stored as a serialised array in wp_options under the key cron. Each entry maps a Unix timestamp (the next run time) to an array of hook names, and each hook name to its schedule interval and arguments. You can inspect this directly:

WP-CLI — inspect scheduled events
# List all scheduled events with next run time and interval wp cron event list # Show all registered schedules (hourly, twicedaily, daily, plus any custom ones) wp cron schedule list # Inspect the raw _cron option wp option get cron

You can also check via PHP. wp_get_scheduled_event( 'my_hook' ) returns the next scheduled instance of a hook — or false if it is not scheduled. wp_next_scheduled( 'my_hook' ) returns the Unix timestamp of the next run, or false.

  HTTP Request (page load)
       │
       ▼
  WordPress bootstrap
       │
       ▼
  spawn_cron() — reads _cron option from wp_options
       │
       ├── No events due? → skip, continue serving request
       │
       └── Events due?
              │
              ▼
         Non-blocking HTTP GET → /wp-cron.php?doing_wp_cron
              │   (separate PHP process)
              │
              ▼
         Set transient lock (wp_doing_cron)
              │
              ▼
         Run all due action hooks
              │
              ▼
         Update _cron option (reschedule recurring events)
              │
              ▼
         Release lock, exit

  (Original page request continues unblocked)

The lock — a transient named doing_cron — is set at the start of the cron run and expired automatically. Its purpose is to prevent two simultaneous page loads from each spawning a cron process that runs the same events twice. As we will see, this protection is weaker than it appears.

/ The Problem

Why WP-Cron fails silently in production

The traffic dependency is the most obvious issue: if nobody visits your WordPress site, scheduled events do not run. A plugin that sends a digest email at 08:00 every morning will not send if traffic to the site is zero at that moment. On low-traffic sites — internal tools, staging environments, B2B portals with off-hours downtime — this is not a theoretical edge case. It is the default outcome.

But traffic-dependent execution is not the only failure mode. There are four others that catch developers by surprise:

Race conditions under concurrent load. The transient-based lock assumes that only one process will check and set the lock at the same time. Under traffic spikes, two simultaneous requests can both read an unlocked state before either writes the lock. Both spawn cron processes; both run the same events. For idempotent tasks this is a nuisance. For tasks that send emails, charge cards, or write external records, it is a bug.

DISABLE_WP_CRON set with no replacement. Many managed WordPress hosts pre-set define( 'DISABLE_WP_CRON', true ); in wp-config.php. Some configure a system cron replacement. Many do not, or they do and then you migrate to a new server. The result: the constant silently disables WP-Cron's traffic-triggered mechanism, and nothing takes its place. No events run. No errors appear. Scheduled jobs accumulate in the _cron option with timestamps that grow increasingly stale.

PHP time limits cutting events short. WP-Cron runs inside a PHP process, subject to max_execution_time. If a single cron event takes longer than the remaining time budget, PHP kills the process. The event may be partially executed, or subsequent events in the same run may never start. Because the exception is caught at the server level rather than in WordPress, no error is written to the WordPress debug log.

Slow requests suppressing cron entirely. spawn_cron() uses a transient timeout to rate-limit how often a new cron process is spawned — by default, at most once every 60 seconds. If a slow page request triggers a cron spawn and the cron process runs for 90 seconds, the next page load within that window will see the transient still set and skip spawning. On sites with slow cron events and moderate traffic, this can cause events to fire far less frequently than their registered schedule.

WP-Cron is a polite fiction: it works well enough on small, consistent-traffic sites where the cost of an occasional missed event is low. In production environments — especially those running background job queues, scheduled notifications, or webhook dispatch workers — it is not a reliable foundation.

/ Comparison

WP-Cron vs System Cron vs Action Scheduler

Aspect WP-Cron (default) System Cron Action Scheduler
Trigger mechanism HTTP requests (traffic-dependent) System daemon (time-based) System daemon + internal queue runner
Reliability Skips on zero traffic Runs on schedule, always Runs on schedule, recovers from PHP crashes
Parallel safety Race condition under load Single process (safe but sequential) Locks per-action, parallel batch runners
Job persistence Lost if PHP process dies mid-run Lost if PHP process dies mid-run Persisted in DB; survives PHP crashes
Failure visibility Silent — no error log by default System logs only Per-action status, error messages, admin UI
Retry on failure None None (must be implemented manually) Built-in; configurable attempts
Scalability Sequential, single PHP thread Sequential, single PHP thread Concurrent batch processing
Setup complexity Zero (built into WordPress) Low (one crontab entry) Requires Composer or WooCommerce dependency

The choice is not always obvious. A system cron job calling wp-cron.php is the right fix for the majority of production sites — it is simple, reliable, and requires no new dependencies. Action Scheduler becomes the right answer when you need persistent job storage, failure tracking, or parallel processing of variable-length queues.

/ Setup

Disabling WP-Cron and configuring a real cron job

Configuring a reliable cron job for WordPress is a two-step process. First, tell WordPress to stop piggybacking cron on page loads. Second, configure a system cron job to call wp-cron.php on a fixed schedule.

Step 1 — disable traffic-triggered execution. Add the following constant to wp-config.php, above the line that reads /* That's all, stop editing! */:

wp-config.php — disable traffic-triggered WP-Cron
/** * Disable WP-Cron's page-load-triggered execution. * You MUST configure a system cron job alongside this constant, * or no scheduled events will run at all. */ define( 'DISABLE_WP_CRON', true );

Step 2 — add a system cron entry. Open the crontab for the user that runs your web server (or the www-data / nginx / apache user, depending on your server configuration):

crontab — run WP-Cron every minute via wget
# Run WordPress scheduled events every minute * * * * * wget -q -O - https://example.com/wp-cron.php?doing_wp_cron >/dev/null 2>&1 # Alternative: use curl instead of wget * * * * * curl -s https://example.com/wp-cron.php?doing_wp_cron &>/dev/null

Replace https://example.com with your actual site URL. The doing_wp_cron query parameter tells WordPress that this is an intended cron request, not a regular page load, so it skips the lock check and runs all due events immediately.

One minute is the standard interval. WordPress's internal scheduler can only meaningfully schedule events at intervals that are a multiple of the cron trigger frequency — so triggering every minute gives you the finest granularity available.

WP-CLI alternative. On servers where outbound HTTP from the cron user is blocked (or you prefer not to add HTTP overhead), WP-CLI provides a direct alternative:

crontab — run WP-Cron via WP-CLI (no HTTP)
# Run due events directly via WP-CLI (no HTTP request overhead) * * * * * cd /var/www/html && wp cron event run --due-now --path=/var/www/html --url=https://example.com --quiet # Or with an absolute path to the wp binary * * * * * /usr/local/bin/wp cron event run --due-now --path=/var/www/html --allow-root --quiet

WP-CLI's approach bootstraps WordPress directly in the CLI process rather than making an HTTP call. It is faster and avoids any firewall or TLS certificate issues that might block the wget/curl approach. The trade-off is that WP-CLI must be installed and the correct PHP binary must be available in the cron environment.

In containerised environments (Docker, Kubernetes), the recommended pattern is to run a separate container or sidecar that executes wp cron event run --due-now on a fixed interval, targeting a shared filesystem mount. This keeps cron execution separate from your web-serving containers and allows independent scaling.

/ Custom Events

Registering and scheduling custom cron events

Custom scheduled events follow a consistent pattern: register a custom schedule interval if you need something other than the built-in hourly, twicedaily, and daily; schedule the event on plugin activation with a guard to prevent double-scheduling; bind a callback to the event hook; and clean up on deactivation.

plugin.php — register a custom every-5-minutes cron event
/** * 1. Register a custom schedule interval. * Hook: cron_schedules (filter) */ add_filter( 'cron_schedules', function( $schedules ) { $schedules['every_five_minutes'] = [ 'interval' => 300, // seconds 'display' => 'Every 5 Minutes', ]; return $schedules; } ); /** * 2. Schedule the event on plugin activation. * Guard with wp_next_scheduled() to avoid duplicate entries. */ register_activation_hook( __FILE__, function() { if ( ! wp_next_scheduled( 'my_plugin_process_queue' ) ) { wp_schedule_event( time(), // first run: now 'every_five_minutes', // schedule name 'my_plugin_process_queue' // hook name ); } } ); /** * 3. Clear the event on plugin deactivation. * Leaving stale events behind causes confusion and may trigger * PHP notices if the callback function no longer exists. */ register_deactivation_hook( __FILE__, function() { $timestamp = wp_next_scheduled( 'my_plugin_process_queue' ); if ( $timestamp ) { wp_unschedule_event( $timestamp, 'my_plugin_process_queue' ); } } ); /** * 4. Bind the callback to the scheduled hook. */ add_action( 'my_plugin_process_queue', function() { // Read pending jobs from your queue table and process them. $jobs = my_plugin_get_pending_jobs( 25 ); // batch of 25 foreach ( $jobs as $job ) { my_plugin_dispatch( $job ); } } );

A few things worth being deliberate about here. The wp_next_scheduled() guard in the activation hook is not optional — without it, repeated plugin deactivations and reactivations will accumulate duplicate scheduled events in the _cron option, each firing independently. The callback will run multiple times per interval.

The deactivation hook must use wp_unschedule_event() with the actual timestamp, not just the hook name. wp_clear_scheduled_hook( 'my_plugin_process_queue' ) is a simpler alternative that removes all instances of a hook regardless of timestamp — it is the safer choice if you are not certain whether one or multiple instances have been scheduled.

Keep the callback fast. WP-Cron (and even system cron) executes the callback synchronously within the PHP process. If you have a large queue, process it in batches across multiple cron runs rather than attempting to drain it entirely in one execution — PHP time limits apply regardless of how cron is triggered.

/ Action Scheduler

When WP-Cron isn't enough: Action Scheduler

Action Scheduler is a background job library for WordPress, originally developed by Automattic for WooCommerce and now used by WooCommerce Subscriptions, WooCommerce Payments, and dozens of other major plugins. It is available as a standalone Composer package (woocommerce/action-scheduler) and ships bundled with WooCommerce.

The key difference from WP-Cron is that Action Scheduler stores jobs in a dedicated database table rather than the _cron option. This gives you: durable storage that survives PHP crashes, per-job status tracking (pending, in-progress, complete, failed, canceled), built-in retry logic, and a UI under WooCommerce → Status → Scheduled Actions (or directly at /wp-admin/admin.php?page=action-scheduler).

The API is straightforward:

action-scheduler-usage.php — schedule and handle deferred actions
/** * Schedule a one-off action to run in 30 seconds. * as_schedule_single_action() returns the action ID. */ $action_id = as_schedule_single_action( time() + 30, // Unix timestamp: when to run 'my_plugin_send_webhook', // hook name [ 'order_id' => 1234 ] // args passed to the hook ); /** * Schedule a recurring action every 5 minutes. * Use a group name to organise actions by feature or plugin. */ as_schedule_recurring_action( time(), // first run: now 300, // interval in seconds 'my_plugin_process_queue', // hook name [], // no args 'my-plugin' // group (for UI filtering) ); /** * Handle the scheduled hook. Action Scheduler passes the * scheduled args as individual parameters. */ add_action( 'my_plugin_send_webhook', function( $order_id ) { $result = wp_remote_post( 'https://endpoint.example.com/webhook', [ 'body' => wp_json_encode( [ 'order_id' => $order_id ] ), 'headers' => [ 'Content-Type' => 'application/json' ], 'timeout' => 15, ] ); if ( is_wp_error( $result ) || wp_remote_retrieve_response_code( $result ) >= 400 ) { // Throwing an exception marks the action as failed in Action Scheduler's log. // Action Scheduler will retry based on its configured retry policy. throw new \Exception( 'Webhook delivery failed for order ' . $order_id ); } } );

Queue issues to know about. Action Scheduler's queue runner processes actions in batches. The default batch size is 25 actions per run, and the queue runner itself is triggered by WP-Cron (or your system cron). If you have a large queue — thousands of pending actions — the batch size and runner frequency become important tuning parameters. You can adjust them via filters:

action-scheduler-tuning.php — adjust batch size and concurrency
// Increase batch size (default: 25) add_filter( 'action_scheduler_queue_runner_batch_size', function() { return 50; } ); // Increase concurrent runners (default: 1) // More runners = more parallel batch processing, but higher DB load add_filter( 'action_scheduler_queue_runner_concurrent_batches', function() { return 3; } ); // Extend time limit for each runner (default: 30 seconds) add_filter( 'action_scheduler_queue_runner_time_limit', function() { return 60; } );

Stuck jobs. An action gets stuck in in-progress status when the PHP process running it was killed before it could mark the action complete or failed. Action Scheduler has a claim timeout (default 5 minutes) after which a stuck action is released back to the queue and retried. If you see many in-progress actions persisting, either your PHP time limit is lower than the claim timeout, or the actions themselves are hanging on a blocking call.

When to use Action Scheduler vs WP-Cron. Use WP-Cron (with a real system cron behind it) for simple, low-frequency, idempotent tasks where losing a single run occasionally is acceptable. Reach for Action Scheduler when the task involves a growing queue of individual jobs, requires per-job failure tracking, or must not lose work if a PHP process dies mid-execution.

/ Debugging

Diagnosing cron problems: tools and techniques

Most WP-Cron problems are invisible by default. Events that should have run 3 hours ago show no error — they simply have not run. The first step in diagnosing any cron issue is making the schedule visible.

WP-CLI is the fastest way to diagnose a cron job for WordPress that is not running as expected. These commands are safe to run on a live site and do not modify state:

WP-CLI — cron diagnostics reference
# Show all scheduled events: hook name, next run, interval, args wp cron event list # Show events sorted by next run time (overdue events appear first) wp cron event list --fields=hook,next_run_relative,recurrence --orderby=next_run_timestamp # Manually run a specific hook (useful for testing without waiting) wp cron event run my_plugin_process_queue # Run all overdue events immediately wp cron event run --due-now # Show registered schedules (intervals and their labels) wp cron schedule list # Delete a specific scheduled event (by hook name) wp cron event delete my_plugin_process_queue

If wp cron event list shows events with a next_run_relative of "3 hours ago" or more, the cron system is not executing reliably. Check whether DISABLE_WP_CRON is set and whether your system cron job is actually running.

Checking the raw cron option. The _cron option holds the canonical scheduler state. You can inspect it directly from MySQL or via WP-CLI:

Check _cron option — raw scheduler state
# Via WP-CLI (truncated output; use --format=json for full detail) wp option get cron # Via MySQL — unserialise to readable form wp db query "SELECT option_value FROM wp_options WHERE option_name = 'cron'" | php -r "echo serialize(unserialize(file_get_contents('php://stdin')));"

The structure is a PHP serialised array: the outer keys are Unix timestamps (next run times), the inner keys are hook names. An event with a timestamp far in the past means it was scheduled but never executed and never rescheduled — the cron runner is not running.

WordPress Site Health. Navigate to Tools → Site Health → Info → Scheduled Events. This section reports whether WP-Cron has run within the last hour and lists any late events. It is less detailed than WP-CLI but accessible without server access.

Action Scheduler admin UI. If you are using Action Scheduler, the full job log is available at /wp-admin/admin.php?page=action-scheduler. You can filter by status (pending, in-progress, complete, failed), search by hook name or group, and manually trigger or cancel individual actions.

Logging missed cron runs. WP-Cron has no built-in logging. If you need to know when the cron runner actually executes — and when it does not — add a lightweight logging hook:

mu-plugins/cron-heartbeat.php — log cron execution times
/** * Log every WP-Cron execution with a timestamp. * Stored as a WordPress option; rotate it periodically. * Drop this in mu-plugins/ so it loads regardless of plugin state. */ add_action( 'init', function() { if ( defined( 'DOING_CRON' ) && DOING_CRON ) { $log = get_option( 'my_cron_heartbeat_log', [] ); $log[] = current_time( 'mysql', true ); // Keep only the last 100 entries. $log = array_slice( $log, -100 ); update_option( 'my_cron_heartbeat_log', $log, false ); } } );

If you cannot see your scheduled jobs, you cannot debug them. Invest in visibility early: WP-CLI, Site Health, Action Scheduler's admin UI, and a heartbeat log will surface most cron problems within minutes. Discovering that cron has not run in 6 hours during an incident is a different experience from seeing it in a dashboard.

/ Webhook Delivery

How cron reliability feeds webhook delivery

In any production webhook system, cron is the pump. A background worker — triggered on a schedule — reads jobs from a queue table, dispatches them as HTTP requests to remote endpoints, and records the result. If the pump stops, the queue fills. If the pump runs intermittently, deliveries are delayed and retries pile up out of sequence.

The failure chain looks like this: WP-Cron misfires on a low-traffic night → the queue worker does not run → 200 pending webhook jobs accumulate → when cron resumes, all 200 run simultaneously → the destination endpoint receives an unexpected spike → it starts returning 429 or 503 → the worker marks the jobs as failed → retry scheduling kicks in → the downstream system receives events hours late or not at all.

This is not a hypothetical. It is the standard failure mode for WordPress webhook systems that use the default WP-Cron scheduler. The solution is the same as for any cron reliability problem: set up a proper cron job for WordPress at the system level — one that fires every minute, regardless of traffic. With a reliable pump, the queue drains predictably and retry logic has a chance to work as designed.

There is a second, less obvious cron-related failure: the delivery window. If your webhook queue is designed to deliver events within 5 minutes of the triggering action, but WP-Cron only fires every 15–20 minutes due to traffic patterns, that SLA is broken by design — not by a bug in the queue or the endpoint. Cron interval is a first-class parameter in any webhook delivery architecture.

For a deeper look at how WordPress webhook dispatch fails — including timeout handling, silent errors, and what good delivery logging looks like — see Why WordPress Webhooks Silently Fail in Production and Why WP-Cron Is Not Enough for Async Webhooks.

/ References

Official documentation

All implementation patterns in this article use WordPress-native APIs and documented constants. Primary references:

/ Production Alternative

If you'd rather not maintain this yourself

Flow Systems Webhook Actions is an open-source async webhook plugin for WordPress that ships with reliable queue processing, exponential backoff retry, and structured delivery logging built in. It uses Action Scheduler as its job runner, so the delivery pipeline is backed by a persistent DB queue rather than the transient-based WP-Cron lock. Delivery logs, retry triggers, and queue status are accessible programmatically via REST API — see the REST API monitoring article and the full endpoint reference. The plugin is free, open-source, and available on GitHub and WordPress.org. Full details on the async webhook plugin page.

/ FAQ

Common questions

No. WP-Cron is not a real scheduler. It is a pseudo-cron system that piggybacks on incoming HTTP requests. When someone visits your WordPress site, the bootstrap process checks whether any scheduled events are overdue and runs them if so.

If no one visits your site, no scheduled events run — regardless of their registered schedule. On low-traffic sites or during off-hours, this means events can be delayed by minutes, hours, or indefinitely. The fix is to disable WP-Cron's traffic-triggered mechanism (DISABLE_WP_CRON) and replace it with a system cron job.
The fastest method is WP-CLI: wp cron event list shows all scheduled events and their next run time. If events show a next_run_relative far in the past, cron is not executing reliably.

You can also check via WordPress admin at Tools → Site Health → Info → Scheduled Events — this reports whether WP-Cron has run within the last hour and flags late events. For lower-level inspection, wp option get cron shows the raw serialised _cron option.
Use WP-Cron (backed by a real system cron) for simple, low-frequency, idempotent tasks: a weekly email digest, pruning old log records, a nightly database cleanup. The setup is zero-dependency and sufficient for tasks where an occasional missed run has low cost.

Reach for Action Scheduler when you need: persistent job storage that survives PHP crashes, per-job status tracking, built-in failure logging and retry, parallel batch processing of large queues, or a UI to inspect and manage individual jobs. Any task that processes a variable-length queue of individual records — including webhook dispatch — benefits from Action Scheduler's durability.
Nothing runs. DISABLE_WP_CRON tells WordPress to stop triggering cron on page loads. It does not create a replacement. If no system cron entry calls wp-cron.php or wp cron event run --due-now, all scheduled events — core WordPress maintenance tasks, plugin jobs, your custom schedules — silently stop executing.

This is a common misconfiguration on managed hosting environments that pre-set the constant without guaranteeing a replacement cron job. Check your hosting control panel for a "WP-Cron" or "WordPress Cron" configuration option, or add the system cron entry yourself.
In any async webhook architecture, a background worker driven by a cron schedule reads queued jobs and dispatches them as HTTP requests. If that cron job misfires, jobs accumulate in the queue unprocessed. The downstream system does not receive events within the expected window, retries are delayed proportionally, and under severe queue buildup the delivery spike when cron resumes can overwhelm the receiving endpoint.

Reliable cron execution is not optional in a production webhook pipeline — it is the pump that keeps delivery moving. Replacing WP-Cron with a system cron job is the single most impactful reliability improvement for WordPress webhook systems on low-to-medium-traffic sites.