Synchronous webhook calls block PHP execution, expose users to third-party latency, and silently drop data when endpoints are unavailable. This article explains how to move webhook dispatch out of the request cycle entirely — using a queue, a cron worker, exponential backoff, and structured logging.
PHP is single-threaded and synchronous. When WordPress fires an action hook — say, woocommerce_order_status_completed — every listener attached to that hook runs inline, before the response is sent to the browser. If one of those listeners makes an outbound HTTP request to a webhook endpoint, the entire request stalls until that endpoint responds.
That's fine in a best-case scenario where the endpoint is fast and always available. In practice, it isn't. External APIs go down. CDNs throttle. A Zapier or n8n webhook URL can take three to five seconds to acknowledge. A slow network handshake multiplied across hundreds of WooCommerce orders per hour means checkout pages routinely take five to ten seconds longer than they should — or fail entirely if the endpoint returns a 5xx or times out.
The failure mode is worse than slowness: if the request times out, the data is lost. There is no retry, no log entry, no signal that delivery failed. The order completed from WooCommerce's perspective but your CRM, ERP, or automation platform never received the event.
Non-blocking webhook dispatch solves all three problems. The user-facing request is never held up by an outbound HTTP call. Failed deliveries are retried automatically. Every attempt — success or failure — is observable.
| Aspect | Synchronous | Asynchronous |
|---|---|---|
| Execution model | Inline, blocks PHP thread | Background worker, non-blocking |
| Timeout risk | Times out → data lost silently | Worker retries on failure |
| Retry on failure | None — one attempt only | Configurable retry schedule |
| Impact on user request | Adds endpoint latency to page load | Zero impact on response time |
| Observability | No log; silent failures | Per-attempt status, code, timestamp |
| Implementation complexity | Low — one wp_remote_post call |
Requires queue table + cron worker |
The trade-off is clear: synchronous delivery is simpler to write but fragile in production. Asynchronous dispatch requires more upfront infrastructure but eliminates the most common failure modes — and is the correct choice for anything beyond development or very low-volume sites.
User Request
│
▼
WordPress Action Hook (e.g. woocommerce_order_status_completed)
│
▼
Listener: writes job to queue table
{ endpoint, payload, attempt=0, status=pending }
│
▼
Response sent to user ◄── request ends here
│
(background)
▼
Cron Worker (WP-Cron or system cron)
│
├── Fetch pending jobs (batch)
│
▼
wp_remote_post( $endpoint, $payload )
│
├─ 2xx → mark job complete, log success
│
└─ failure → increment attempt, schedule retry
│
└─ attempt >= max → move to dead-letter, alert
Queue table. Jobs are stored in a custom database table, not in the options or postmeta tables. This gives you efficient queries by status, fast batch fetching, and clean cleanup — none of which are practical with the WordPress options API at scale.
Worker. A WP-Cron event (or a system cron hit to a REST endpoint) runs on a regular interval — every minute is typical — and processes a batch of pending jobs. System cron is preferred for reliability: WP-Cron only runs when someone visits the site, which is not guaranteed. For a full guide on setting up a real cron job for WordPress — including the crontab entry, WP-CLI alternative, and Action Scheduler — see Cron Job for WordPress: WP-Cron Limits and Real Fixes.
Retry scheduling. When a job fails, the worker does not immediately requeue it. Instead, it calculates the next attempt time using exponential backoff and sets a scheduled_at timestamp. The job becomes visible to the next worker run only after that time elapses.
Dead-letter. After a configurable maximum number of attempts, the job is moved to a permanent failure state rather than retried indefinitely. This prevents a single bad endpoint from consuming queue capacity forever.
The listener is registered with add_action. When the action fires, the listener builds the payload and writes a row to the queue table. Nothing is dispatched at this point — that happens later, in the background.
The payload is serialised as JSON at enqueue time, not at dispatch time. This ensures the data captured reflects the state of the order at the moment the action fired — even if the order is modified before the worker runs. The scheduled_at column controls when the worker first picks up the job; on initial insert it's set to now, so the job is eligible immediately.
Simple retry — "try again immediately on failure" — is usually the wrong approach. It floods a recovering endpoint with requests, potentially making the outage worse. Exponential backoff spaces retries further apart on each successive failure, giving the endpoint time to recover.
The formula is straightforward:
With a base delay of 60 seconds (1 minute), the retry schedule looks like this:
A few details worth noting. The timeout on wp_remote_post is set to 10 seconds — longer than the default 5-second WordPress HTTP timeout, which is often insufficient for automation platform webhooks. Increase this if your endpoint is consistently slower. Add jitter (a small random offset applied to each retry delay) if you're dispatching high volumes — it prevents multiple failed jobs from retrying at exactly the same second and thundering-herding the endpoint.
Not every failure is transient. An endpoint that has been decommissioned, a URL that now returns 404, or a payload that the receiver rejects with 400 — these won't be fixed by retrying. After a configured maximum number of attempts, the job should be moved to a dead-letter state rather than left in the retry queue indefinitely.
Mark jobs as permanently failed after five to ten attempts, depending on the criticality of the data. Store the final HTTP status code and response body alongside the job record so the failure reason is inspectable without needing to reproduce the error.
Use is_wp_error() to catch network-level failures — DNS resolution errors, connection refused, SSL handshake failures. These are separate from HTTP-level failures (4xx, 5xx) where the endpoint was reached but rejected the request.
Treat 4xx responses differently from 5xx. A 400 Bad Request or 422 Unprocessable Entity is unlikely to resolve itself — the payload is malformed from the endpoint's perspective. Retrying these wastes attempts. Consider marking 4xx-triggered jobs as failed immediately (or after one confirmation attempt) rather than exhausting the full retry schedule.
A 503 Service Unavailable or a network timeout, by contrast, is exactly what exponential backoff is designed for.
When a job transitions to the failed state, trigger an alert. At minimum, write to the WordPress error log via error_log(). For production systems, fire a do_action hook that can be wired to an email notification, a Slack message, or an internal monitoring endpoint. Unmonitored dead-letter queues that silently accumulate are as bad as no queue at all. Dead-letter events also need a recovery path: once the underlying problem is fixed, they should be replayable on demand — see the full retry and replay architecture for how that works.
A queue without observability is a black box. At minimum, log the following fields for every dispatch attempt:
Store the payload hash rather than the raw payload in the log table to avoid accidentally persisting sensitive order or user data in a secondary location. The full payload is already stored in the queue table against the job record.
Track pending and failed job counts as operational metrics. A growing pending queue that isn't draining indicates the cron worker isn't running. A growing failed queue indicates a systematic endpoint problem. Both are actionable signals.
Expose a simple count query via a WP-Admin page or a WP-Cron-adjacent status panel. For automated monitoring, add a REST endpoint that returns queue stats as JSON — it takes ten minutes to write and can be polled by any uptime tool.
WP-Cron is not a real cron. It fires on page load, which means on a low-traffic site, your worker might not run for minutes or hours. For production, configure a real system cron entry that hits the WordPress cron URL directly (or a dedicated REST endpoint) on a fixed schedule:
All implementation patterns described here use WordPress-native APIs. These are the primary references:
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 REST API — the REST API article covers monitoring, bulk retry, and automated recovery patterns in detail, with the full endpoint reference here. 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.
wp_remote_post is a synchronous HTTP call: PHP waits for the remote server to respond before continuing execution. Async webhook dispatch wraps that call inside a background job — the request is written to a queue during the user-facing action and dispatched later by a cron worker.wp_remote_post call cannot do.
dbDelta), an add_action listener that writes to that table, and a WP-Cron event that reads from the table and dispatches via wp_remote_post.wp_remote_post timeout is 5 seconds. Many automation platform webhooks (n8n, Zapier cold starts) take longer. Increase the timeout to 10–15 seconds.