/ Article — Background Processing

WordPress Async Background Processing: Jobs Without Blocking

Synchronous PHP blocks every page load. This article covers non-blocking wp_remote_post, WP-Cron deferred execution, and Action Scheduler persistent job queuing — with the trade-offs that decide which tool belongs in production.

TL;DR

  • blocking => false in wp_remote_post fires the HTTP call without waiting for a response — but provides no retry or error visibility
  • WP-Cron defers work to the next page load, not a real timer — use a system cron entry to guarantee minute-level execution
  • Action Scheduler stores jobs in DB tables, retries failures with exponential backoff, and keeps a full attempt log
  • The Webhook Actions plugin uses Action Scheduler automatically — no manual queue code required for reliable webhook delivery
/ Overview

What is async background processing in WordPress?

"Async" in WordPress means decoupling work from the current HTTP request so the user's response returns immediately while the heavy work runs later — in a separate process, triggered by a queue runner or cron. Every email, order webhook, or file import that cannot fail silently needs this separation.

WordPress has three built-in mechanisms for background work: non-blocking HTTP requests (blocking => false), scheduled events via WP-Cron, and background job queues powered by Action Scheduler. Each has different guarantees and failure modes.

/ Why Blocking Fails

Why does synchronous HTTP block the WordPress response?

PHP executes synchronously within a single HTTP request. When WordPress calls wp_remote_post(), PHP opens a TCP connection, sends the request, and waits for the remote server to respond — all before returning the page to the visitor. A slow or unavailable endpoint can add seconds to page load, trigger PHP's execution timeout, and cause the triggering event to appear to fail even if WordPress itself completed successfully.

This is the root cause of most silent webhook failures. The admin action (status change, form submission) completes, but the downstream HTTP call times out or throws an exception that goes unlogged. No queue, no retry, no visibility.

SYNCHRONOUS (blocks request)

  Browser           WordPress          Remote API
    │                   │                   │
    │── POST /checkout ─>│                   │
    │                   │── wp_remote_post ─>│  ← PHP waits here
    │                   │                   │  (could be 5-30 s)
    │                   │<─ 200 / timeout ──│
    │<── response ──────│                   │
    │  (blocked until   │                   │
    │   API responds)   │                   │
/ Non-Blocking Requests

How do you make a non-blocking fire-and-forget request?

Pass 'blocking' => false to any wp_remote_post or wp_remote_request call. WordPress's HTTP API initiates the connection and returns control to PHP immediately, without reading the response body or status code.

PHP — non-blocking wp_remote_post
wp_remote_post( $url, [ 'method' => 'POST', 'body' => wp_json_encode( $payload ), 'headers' => [ 'Content-Type' => 'application/json' ], 'blocking' => false, // return immediately, don't read response 'timeout' => 0.01, // very short — we're not waiting anyway 'sslverify' => true, 'data_format' => 'body', ] );

Limitation: with blocking => false you get no response, no status code, no error information, and no retry if the endpoint is down. This pattern suits simple fire-and-forget notifications (logging, analytics pings) where missing one event is acceptable. For webhook delivery where every event must arrive, a persistent queue is necessary.

/ WP-Cron Deferred

How does WP-Cron defer background work outside the request?

WP-Cron is WordPress's built-in scheduler. You schedule a hook with wp_schedule_single_event( time() + 5, 'my_hook', $args ), then register a handler with add_action(). On the next page load after the scheduled time, WordPress fires the hook in a separate process — outside the current request but still driven by HTTP traffic.

The critical limitation: WP-Cron fires on page load, not on a real timer. A low-traffic site at 3 AM may wait hours before the next page load triggers the queue. Use a real system cron (*/1 * * * * curl -s https://site.com/wp-cron.php?doing_wp_cron) to get minute-level reliability. The WP-Cron developer reference covers the full function set.

/ Action Scheduler

How does Action Scheduler decouple jobs from the request cycle?

Action Scheduler goes further than WP-Cron: it stores every pending action in its own database tables, runs a dedicated queue runner via a system-level process (or as a WP-Cron job as fallback), and keeps a full attempt log per action. When an action fails, it is automatically retried with exponential backoff rather than silently dropped.

The queue runner claims a batch of pending actions, marks each as processing, executes the corresponding do_action() call, and marks the result complete or failed. Because claim locking is DB-level, multiple concurrent runners cannot process the same action twice — which is the core guarantee that makes async dispatch reliable at scale.

ASYNC WITH ACTION SCHEDULER

  Browser           WordPress              Queue Runner
    │                   │                        │
    │── POST /checkout ─>│                        │
    │                   │── as_enqueue_async ─>  DB
    │<── 200 ───────────│   (row inserted)       │
    │  (fast response)  │                        │
    │                   │         ┌─── pulls pending actions
    │                   │         │    fires do_action($hook)
    │                   │         │    calls wp_remote_post
    │                   │         └─── retries on 5xx
/ Queue Job Pattern

How do you implement a background queue job in WordPress?

The pattern has two parts: enqueue the work, and register the handler. With Action Scheduler, enqueuing is one line. The handler is a standard WordPress action callback — it can do any work that doesn't need to return a value to the calling request.

PHP — queue job pattern with Action Scheduler
// 1. Enqueue the job when the event fires add_action( 'woocommerce_payment_complete', function( $order_id ) { as_enqueue_async_action( 'my_plugin_send_crm_webhook', [ 'order_id' => $order_id ], 'webhooks', true ); } ); // 2. Handle the job in the queue runner process add_action( 'my_plugin_send_crm_webhook', function( $order_id ) { $order = wc_get_order( $order_id ); $payload = [ 'email' => $order->get_billing_email(), /* ... */ ]; $response = wp_remote_post( $crm_url, [ 'body' => wp_json_encode( $payload ), 'headers' => [ 'Content-Type' => 'application/json' ], ] ); if ( is_wp_error( $response ) ) { throw new \Exception( $response->get_error_message() ); // Action Scheduler catches the exception and retries } } );
/ Comparison

Blocking PHP vs async background dispatch: what changes?

ConcernBlocking wp_remote_postWebhook Actions (async queue)
Response timeAdds endpoint latency to every page loadReturns immediately — job runs in queue runner
Retry on failureNone — one attempt, silent dropExponential backoff: 1 m → 2 m → 4 m → 8 m, 5 attempts
Error visibilityWP_Error swallowed unless you log it yourselfAttempt log with HTTP status and response body
Delivery guaranteeBest-effort — timeout or 5xx = lostPersistent queue — survives PHP crash or server restart
Request isolationFailure aborts the current user requestJob failure never affects the triggering request
/ Plugin Option

How does Webhook Actions handle async dispatch automatically?

The Webhook Actions plugin wraps the entire queue-job pattern described above. You bind a hook to an endpoint in wp-admin; the plugin enqueues every triggered delivery via Action Scheduler (or WP-Cron as fallback), injects identity headers (X-Event-Id, X-Event-Timestamp, X-Webhook-Id), retries 5xx failures with exponential backoff, and logs every attempt in a queryable delivery log — all without writing PHP.

Custom payload shaping is available via the fswa_webhook_payload filter or via Code Glue snippets (Pro). For integrations that need conditional dispatch, the fswa_should_dispatch filter lets you skip delivery based on payload fields, order status, or any WordPress state — keeping the queue clean rather than delivering and discarding on the receiving end.

See the async webhooks article for the end-to-end architecture, and retry and replay for the backoff schedule and manual replay workflow.

/ FAQ

Common questions

It instructs WordPress's HTTP API to open the connection and send the request without waiting for a response. PHP returns control to your code immediately. The response body is never read, so you cannot check the HTTP status code or detect errors. Use it only when delivery confirmation is not required.
No. If the remote server is unreachable or the OS has not finished flushing the TCP send buffer before PHP terminates, the request may not be sent at all. blocking => false is fire-and-forget at the PHP layer, not a guaranteed-delivery mechanism. For reliable delivery, use Action Scheduler with a persistent queue and automatic retry.
Not by default. WP-Cron fires on page load, so it depends on incoming HTTP requests to tick. On low-traffic sites the queue may sit idle for hours. The standard fix is to disable WP-Cron in wp-config.php (define('DISABLE_WP_CRON', true)) and call wp-cron.php from a real system cron every minute.
WP-Cron stores scheduled events in a single wp_options row with no attempt history, no retry, and no delivery log. Action Scheduler uses dedicated database tables, stores every action individually, retries failures automatically with exponential backoff, and keeps a full per-action log. For webhook delivery, Action Scheduler is the right choice.
You can, but you need to add all the reliability infrastructure manually: enqueue the call outside the current request, add retry logic on 5xx responses, store attempt history, and build a replay mechanism. The Webhook Actions plugin provides all of this out of the box, built on Action Scheduler.