TL;DR
- WP-Cron fires only when someone visits the site — on low-traffic WordPress installs, scheduled jobs can be delayed by hours
- Under load, concurrent page requests can trigger the same WP-Cron job simultaneously, causing duplicate webhook dispatches
- Production webhook delivery needs a real system cron entry hitting
wp-cron.phpon a fixed interval, not WP-Cron
/ WP-Cron
Why is WP-Cron not real background processing?
When developers first encounter WP-Cron, it looks like a background task system. You schedule an event with wp_schedule_event, attach a callback with add_action, and WordPress "runs it in the background." Except it doesn't.
WP-Cron fires on page load. Every time a request hits your WordPress site, WordPress checks whether any scheduled events are past due and runs them inline — piggybacking on the visitor's request. There is no daemon, no OS-level scheduler, no separate process. The "cron" is entirely simulated.
This design has a direct consequence: if no one visits your site, WP-Cron does not run. A webhook job scheduled for 2:00 AM might not execute until the first visitor at 9:00 AM. On a staging environment with no traffic, cron events may never fire at all.
There are also race conditions. On high-traffic sites, multiple concurrent requests can each detect the same overdue event and attempt to run it simultaneously. WordPress uses a transient-based lock to mitigate this, but the lock is not guaranteed under heavy load — jobs can fire multiple times.
These are not edge cases. They are the default behavior. Any automation that depends on WP-Cron for timely, exactly-once execution is fragile by design.
/ The Problem
Synchronous webhooks block and silently fail
The most common webhook pattern in WordPress looks like this: attach a callback to an action hook, and inside that callback, fire wp_remote_post to notify an external service. It works in development. In production it causes two categories of problems.
The first problem is blocking. PHP is single-threaded. When wp_remote_post runs, execution pauses until the remote endpoint responds or the request times out. Every millisecond the external service takes is a millisecond added to the user's page load. If you're dispatching webhooks inside WooCommerce checkout hooks, you are directly coupling checkout latency to the availability of a third-party API.
functions.php — the synchronous anti-pattern
// This runs inline during save_post — user waits for the HTTP call to complete. add_action( 'save_post', function( $post_id ) { wp_remote_post( 'https://hooks.example.com/post-saved', [ 'body' => wp_json_encode( [ 'post_id' => $post_id ] ), 'headers' => [ 'Content-Type' => 'application/json' ], ] ); // If the endpoint is down, this silently fails. No retry. No log. } );
The second problem is silent failure. If the endpoint times out, the WordPress default HTTP timeout is 5 seconds — the request is abandoned and the data is gone. There is no retry mechanism, no error log entry visible to the site owner, and no indication that the event was never delivered. Your CRM or automation platform simply never received the notification.
These two problems compound. A slow endpoint makes your site slow. A down endpoint makes your site slow and loses the event. Neither is acceptable in any production automation workflow.
/ Reliability
Automation is infrastructure
A webhook that sometimes delivers is not an integration — it's a liability. When your WooCommerce orders trigger CRM updates, fulfillment notifications, or accounting entries, a missed event means a broken business process. The failure is often invisible until a customer complains or a reconciliation reveals the gap.
Fragile webhook delivery creates a specific category of failure modes that are hard to diagnose and expensive to recover from:
- Silent drops. The webhook fires, the HTTP request times out, nothing is logged. The integration appears to work until you cross-reference source and destination records.
- Traffic-dependent execution. Your automation only runs when users visit the site. Overnight batch events, low-traffic periods, or staging environments where no one browses produce gaps.
- No retry on transient failures. A momentary endpoint outage, a cold-start delay on a serverless function, or a brief network hiccup permanently loses the event.
- Ordering problems. Synchronous delivery doesn't queue — events fire in the order PHP encounters them, with no backpressure or rate limiting against the receiving endpoint.
None of these are hypothetical. They manifest on real WordPress and WooCommerce installations once volume or business criticality crosses a threshold. The fix is an architectural change, not a tweak to existing code.
/ Architecture
Separating event trigger from job execution
The key insight is the separation of concerns. The action hook listener does one thing: record the event. It writes a row to a database table and returns immediately. The user's PHP request is never held open by an outbound HTTP call.
Event trigger. Any WordPress or WooCommerce action hook. The listener captures the data it needs, serialises it as JSON, and inserts a queue row. This is fast — a single database write.
Job storage. A dedicated database table, not the options or postmeta tables. Querying by status and scheduled time is efficient against a properly indexed custom table; it is impractical against postmeta at scale.
Job execution. A background worker, triggered on a regular schedule, reads pending jobs and attempts delivery. It updates the job record on success, reschedules on failure using exponential backoff, and moves permanently failed jobs to a dead-letter state. Every attempt is logged.
This architecture gives you non-blocking dispatch, automatic retry, structured logging, and rate-limiting capability — none of which are possible with synchronous wp_remote_post in a hook callback. For a deeper look at how retry and replay fit together into a complete delivery system, see Inside My Webhook Replay System. For managing the webhook definitions themselves — creating, updating, toggling, and deleting webhooks without touching wp-admin — see Create and Manage WordPress Webhooks Programmatically.
/ Queue Pattern
A minimal async queue in WordPress
WordPress does not ship with a native job queue. ActionScheduler (bundled with WooCommerce) provides one, and there are several standalone options. If you're building custom infrastructure, the core pattern is straightforward: a database table, a listener, and a worker.
The flow from event to delivery looks like this:
- Hook fires
- Data saved to queue
- Background process starts
- Webhook dispatched
- Status updated
Steps 1 and 2 happen synchronously inside the user's request — they are fast (one DB write) and have no external dependencies. Steps 3 through 5 happen in the background, completely outside the user's request cycle.
Common approaches for the queue storage layer include a custom $wpdb table (most control, most work), ActionScheduler's existing tables (already present on WooCommerce sites), or a lightweight plugin that provides a queue API. The choice depends on how much control and how much maintenance overhead you want.
Whichever storage layer you choose, the contract is the same: the listener writes, the worker reads, and the queue tracks state transitions from pending → complete (or failed).
/ System Cron
How does system cron compare to WP-Cron?
Even if your queue architecture is correct, it still needs a reliable trigger. WP-Cron is not that trigger. As described earlier, WP-Cron depends on page visits — if no one loads a page, the worker doesn't run. Jobs accumulate in the queue, unprocessed.
The fix is to use the operating system's real scheduler — cron on Linux — to run the WordPress cron handler on a fixed interval. This is traffic-independent, predictable, and requires no code changes to your queue or worker logic.
crontab — run WordPress cron every minute via PHP CLI
# Run directly via PHP CLI — no HTTP overhead, no web server dependency. # Replace the path with your actual WordPress installation path. */1 * * * * php /var/www/html/wp-cron.php
Disable the default WP-Cron behaviour in wp-config.php once system cron is configured, so that page loads no longer trigger the cron check:
wp-config.php — disable WP-Cron piggybacking on page loads
define( 'DISABLE_WP_CRON', true );
If direct CLI access is not available, an alternative is a token-protected REST endpoint that triggers the cron runner. The system cron then calls that endpoint via curl or wget. This approach works on managed hosting environments where you only have HTTP access:
crontab — trigger via REST endpoint with secret token
# Hit a REST endpoint that requires a token — keeps the trigger secure.
*/1 * * * * curl -s "https://your-site.com/wp-json/my-plugin/v1/run-queue?token=SECRET" > /dev/null 2>&1 Either approach gives you a deterministic, traffic-independent trigger. Your queue worker runs every minute regardless of whether anyone is browsing the site.
/ WooCommerce
WooCommerce magnifies every flaw
WooCommerce is where WP-Cron and synchronous webhook problems are felt most acutely. Checkout is a high-stakes, high-frequency operation. Customers are completing purchases — any latency or failure directly affects conversion rates and revenue.
WooCommerce fires a cascade of action hooks during order processing: woocommerce_checkout_order_created, woocommerce_payment_complete, woocommerce_order_status_completed, and others. If any listener on these hooks makes a synchronous outbound HTTP call, that call's latency is added to the checkout response time.
At low order volumes, the effect is imperceptible. At moderate volumes — dozens of orders per hour — a 500ms average webhook call adds meaningful latency to every checkout. At high volumes, or when an external endpoint has a bad moment, checkout pages begin to hang or return errors.
Async dispatch solves this completely. The checkout hook listener writes a queue row (fast), the response is sent to the customer immediately, and the webhook delivery happens in the background. Checkout speed is never coupled to the availability or latency of a third-party endpoint.
WooCommerce also has the ActionScheduler library built in — a robust, database-backed job queue. If you're building on WooCommerce, you can use ActionScheduler directly rather than writing your own queue table. The pattern is the same; the infrastructure is already there.
/ When To Use What
WP-Cron: acceptable vs not acceptable
| Use Case | WP-Cron OK | WP-Cron Not OK |
|---|---|---|
| Low-traffic hobby site | — | ✓ |
| Non-critical background tasks | — | ✓ |
| Email digests & newsletters | — | ✓ |
| Revenue workflows | ✗ | — |
| CRM sync & lead routing | ✗ | — |
| Payment-related automation | ✗ | — |
The distinction is consequence. If a missed event means a content cache takes an extra hour to clear, WP-Cron's unreliability is tolerable. If a missed event means an order isn't synced to your ERP or a customer isn't enrolled in a course they paid for, it is not.
WP-Cron's reliability is also proportional to traffic. High-traffic sites where every page load is a potential cron trigger get close to minute-by-minute execution. Low-traffic or staging sites can go hours without a trigger. Build your architecture for the worst case, not the average.
/ Modern Architecture
A practical architecture for reliable WordPress webhooks
A production-grade async webhook system for WordPress has seven layers. Each layer has a clear responsibility:
- Event listener. An
add_actioncallback on the WordPress or WooCommerce hook that matters. Builds the payload and enqueues it. Returns immediately — no HTTP calls. - Queue storage. A database table (or ActionScheduler) that holds jobs with status, payload, endpoint, attempt count, and scheduled dispatch time.
- Dispatcher. The worker function that reads pending jobs and calls
wp_remote_post. Handles success (mark complete) and failure (schedule retry or move to dead-letter). - Retry logic. Exponential backoff — each failed attempt reschedules the job further into the future. Prevents thundering-herd behaviour against a recovering endpoint.
- Logging. Per-attempt records: HTTP status code, response body (or error message), duration, timestamp. Essential for diagnosing failures — and queryable programmatically via the delivery log API for monitoring systems and automated recovery scripts.
- Trigger. A REST endpoint or WP-CLI command that the dispatcher runs on. Called by system cron on a fixed interval — every minute is typical.
- System cron entry. The OS-level scheduler that calls the trigger. Traffic-independent, predictable, the only reliable way to guarantee regular execution on a WordPress site.
Layers 1–5 are application code. Layers 6–7 are infrastructure configuration. On managed WordPress hosting where you don't control the OS cron, managed queues or a plugin that handles its own scheduling become the practical alternative.
/ References
Official documentation
All implementation patterns described here use WordPress-native APIs. These are the primary references:
- WP-Cron / Scheduling — how WP-Cron works, registering events, system cron alternatives
- WordPress HTTP API — wp_remote_post, wp_remote_get, response handling
- wp_remote_post() — function reference, arguments, return values
- add_action() — registering callbacks on WordPress action hooks
- do_action() — how WordPress action hooks are fired
- WooCommerce Action Reference — full list of WooCommerce hooks and filters
- Action Scheduler — the robust job queue library bundled with WooCommerce
/ Production Alternative
If you'd rather not maintain this yourself
Flow Systems Webhook Actions is an open-source async webhook plugin for WordPress that implements this architecture out of the box — including queue processing, retry logic with exponential backoff, and structured delivery logging. Delivery logs, retry triggers, and queue status are also accessible programmatically via the REST API. If your team prefers configuration over maintaining custom queue infrastructure, you can explore the full details on the async webhook plugin for WordPress. The code is publicly available on GitHub and distributed via WordPress.org.