Problem: Synchronous HTTP requests (via wp_remote_get()) placed in template loaders, init hooks, or wp_footer actions serialize external API latency into every page request. At scale (100+ concurrent users), this exhausts PHP-FPM worker pools, causing request queuing, 503 errors, and timeouts.
Solution: Implement a Middleware Queue Pattern where WordPress enqueues API tasks to a local persistent queue (ActionScheduler), processes them asynchronously, and caches results. Frontend continues unblocked.
Impact: TTFB drops 80-90%, PHP worker utilization stabilizes, and site remains responsive during API outages
1. The Failure Mode: Blocking Synchronous Requests
Scenario: Direct API Call in Footer Hook
php// ❌ ANTI-PATTERN: Blocking synchronous request
add_action('wp_footer', function() {
$response = wp_remote_get('https://external-api.com/status', ['timeout' => 30]);
if (!is_wp_error($response)) {
echo json_decode(wp_remote_retrieve_body($response))->message;
}
});
Why This Fails at Scale
Request Timeline (100 concurrent users):
- User A lands on homepage → PHP worker #1 allocated
- Worker #1 executes
wp_footer→ callswp_remote_get() - Blocks: PHP worker #1 suspends, waiting for API response (30s default timeout)
- Users B-D land on homepage → PHP workers #2-5 allocated
- Each worker #2-5 also hits
wp_footer→ all block waiting for API - PHP-FPM pool maxed: 10 workers (default) all blocked → User E gets queued
- User E’s request waits 5+ minutes for a worker to free up
- API response finally arrives for User A (25s latency + 10ms API time = 25s wasted)
- Worker #1 frees up → processes User E → repeats cycle
Result:
- TTFB balloons from 200ms to 25-30s
- Core Web Vitals fail (LCP >4s)
- Customer abandonment spikes
- Server appears “down” (high latency, not high CPU)
The Buffer Pool Myth
Naive counter-argument: “We’ll increase pm.max_children to 50 workers.”
This fails because:
- 50 workers × 30s timeout = 25 minutes of cumulative blocking
- Database connection pool exhausts (default: 100 connections, now 40+ blocked)
- Memory usage spikes 10x (each PHP-FPM child = 50-100MB)
- Cost explodes, but site still slow
2. The Middleware Pattern: Queue-Based Abstraction
Architecture:
text┌─────────────┐
│ WordPress │
│ Page Load │
└────────┬────┘
│
▼
┌─────────────────────┐
│ ActionScheduler │ ← Single INSERT, returns instantly
│ (Local Queue) │
└────────┬────────────┘
│
├─ (Async, batched processing)
│
▼
┌──────────────────┐
│ Background Job │
│ (Queue Runner) │ → API Call (blocks only 1 isolated worker)
└──────────────────┘
Core Principle: Decouple page render from API I/O.
Queue Storage Options
| Solution | Storage | Throughput | Use Case |
|---|---|---|---|
| ActionScheduler | Custom post type / table | 1000+ jobs/min | WooCommerce, high-volume |
| WP Cron | Options table | 100 jobs/min | Small sites, simple tasks |
| Custom Queue | Custom table | Unlimited | Enterprise, fine-grained control |
ActionScheduler recommended for stores/complex plugins; WP Cron for lightweight tasks.
3. Implementation: ActionScheduler Async Pattern
Setup (Install Plugin)
bashcomposer require woocommerce/action-scheduler
# OR: Install via WP Admin → Plugins → Add New → "Action Scheduler"
Enqueue to Queue (Synchronous, <5ms)
php<?php
declare(strict_types=1);
namespace MyCompany\Sync;
use ActionScheduler_AsyncRequest_QueueRunner;
class ExternalDataSync {
/**
* Schedule a single API sync task (non-blocking).
* Executes in <5ms on page load.
*
* @param string $endpoint API endpoint
* @param int $retry_count Attempt counter
* @return int Action ID
*/
public static function schedule_api_sync(
string $endpoint,
int $retry_count = 0
): int {
// Enqueue to ActionScheduler
$action_id = as_schedule_single_action(
time() + 5, // Run in 5 seconds (allows batching)
'mycompany_sync_external_api',
[
'endpoint' => $endpoint,
'retry_attempt' => $retry_count,
],
'mycompany-api-sync' // Group (for cleanup)
);
return $action_id;
}
/**
* Execute the API call (runs in background queue runner).
* This function is only called by ActionScheduler worker, not page load.
*
* @param string $endpoint
* @param int $retry_attempt
* @return void
*/
public static function execute_api_sync(
string $endpoint,
int $retry_attempt = 0
): void {
$cache_key = "api_sync_{$endpoint}";
// Avoid duplicate API calls
if (wp_cache_get($cache_key)) {
return;
}
wp_cache_set($cache_key, true, '', 30); // Lock for 30s
$response = wp_remote_get(
"https://external-api.com/{$endpoint}",
[
'timeout' => 60, // Safe: only blocks 1 worker
'user-agent' => 'MyCompany-Sync/1.0',
]
);
if (is_wp_error($response)) {
// Retry logic (handled below)
self::handle_api_failure($endpoint, $retry_attempt, $response);
return;
}
$status_code = wp_remote_retrieve_response_code($response);
if ($status_code !== 200) {
self::handle_api_failure($endpoint, $retry_attempt,
new \WP_Error('api_error', "HTTP {$status_code}")
);
return;
}
// Success: cache and persist
$body = json_decode(wp_remote_retrieve_body($response), true);
self::persist_api_data($endpoint, $body);
}
/**
* Retry with exponential backoff.
* Handles API rate limits and transient failures.
*
* @param string $endpoint
* @param int $current_attempt
* @param \WP_Error $error
* @return void
*/
private static function handle_api_failure(
string $endpoint,
int $current_attempt,
\WP_Error $error
): void {
$max_retries = 5;
if ($current_attempt >= $max_retries) {
// Final failure: alert ops
error_log(
sprintf(
'[API SYNC FAILED] Endpoint: %s, Attempts: %d, Error: %s',
$endpoint,
$current_attempt,
$error->get_error_message()
)
);
return;
}
// Exponential backoff: 5s, 25s, 125s, 625s, 3125s
$backoff_delay = pow(5, $current_attempt + 1);
$scheduled_time = time() + $backoff_delay;
as_schedule_single_action(
$scheduled_time,
'mycompany_sync_external_api',
[
'endpoint' => $endpoint,
'retry_attempt' => $current_attempt + 1,
],
'mycompany-api-sync'
);
error_log(
sprintf(
'[API SYNC RETRY] Endpoint: %s, Retry: %d/%d, Backoff: %ds',
$endpoint,
$current_attempt + 1,
$max_retries,
$backoff_delay
)
);
}
/**
* Persist API data to custom table or options.
*
* @param string $endpoint
* @param array $data
* @return void
*/
private static function persist_api_data(
string $endpoint,
array $data
): void {
// Store in custom cache (not wp_options, which is loaded on every request)
update_option(
"transient_api_response_{$endpoint}",
[
'data' => $data,
'timestamp' => time(),
]
);
// Optional: Store in custom table for analytics
global $wpdb;
$wpdb->insert(
$wpdb->prefix . 'api_sync_log',
[
'endpoint' => $endpoint,
'response' => json_encode($data),
'synced_at' => current_time('mysql', true),
],
['%s', '%s', '%s']
);
}
}
// Hook into ActionScheduler
add_action(
'mycompany_sync_external_api',
[ExternalDataSync::class, 'execute_api_sync'],
10,
2
);
Frontend Usage (Zero Latency)
php<?php
// In template or shortcode
$cached_data = get_option('transient_api_response_endpoint');
if ($cached_data && (time() - $cached_data['timestamp']) < 300) {
// Cache hit: served in <1ms
echo json_encode($cached_data['data']);
} else {
// Cache miss: enqueue sync, show fallback
\MyCompany\Sync\ExternalDataSync::schedule_api_sync('endpoint');
echo '<p>Loading...</p>'; // Or cached stale data
}
4. Rate Limiting and Circuit Breaker Pattern
Problem: External API returns 429 (Too Many Requests).
Naive fix: Retry immediately. (Causes thundering herd.)
Correct fix: Respect Retry-After header, implement exponential backoff.
Rate Limiter Extension
php<?php
declare(strict_types=1);
class APIRateLimiter {
private const RATE_LIMIT_KEY = 'api_rate_limit_status';
private const CIRCUIT_BREAKER_KEY = 'api_circuit_breaker';
/**
* Check if we're hitting rate limits.
* Returns backoff duration, or 0 if safe to proceed.
*
* @param string $endpoint
* @return int Seconds to wait, or 0
*/
public static function get_backoff_duration(string $endpoint): int {
$circuit_state = wp_cache_get(self::CIRCUIT_BREAKER_KEY);
if ($circuit_state === 'open') {
// Circuit is open: API is down, don't slam it
return 300; // Wait 5 minutes before retrying
}
$limit_info = wp_cache_get(self::RATE_LIMIT_KEY);
if ($limit_info && isset($limit_info['retry_after'])) {
// API told us when to retry
return max(0, $limit_info['retry_after'] - time());
}
return 0; // Safe to proceed
}
/**
* Update rate limit state from API response headers.
*
* @param \WP_Remote_Response $response
* @return void
*/
public static function update_from_response($response): void {
$status = wp_remote_retrieve_response_code($response);
$headers = wp_remote_retrieve_headers($response);
if ($status === 429) {
// Rate limited
$retry_after = (int) ($headers['retry-after'] ?? '60');
wp_cache_set(
self::RATE_LIMIT_KEY,
['retry_after' => time() + $retry_after],
'',
$retry_after
);
} elseif ($status >= 500) {
// Server error: open circuit
wp_cache_set(
self::CIRCUIT_BREAKER_KEY,
'open',
'',
300 // Retry after 5 min
);
} else {
// Success: close circuit
wp_cache_delete(self::CIRCUIT_BREAKER_KEY);
}
}
}
Integrated Backoff in execute_api_sync()
php// Before calling wp_remote_get()
$backoff = APIRateLimiter::get_backoff_duration($endpoint);
if ($backoff > 0) {
// Reschedule for later
as_schedule_single_action(
time() + $backoff,
'mycompany_sync_external_api',
['endpoint' => $endpoint, 'retry_attempt' => $retry_attempt],
'mycompany-api-sync'
);
return; // Exit early
}
$response = wp_remote_get(...);
APIRateLimiter::update_from_response($response);
5. Monitoring and Observability
New Relic / DataDog Instrumentation
php<?php
add_action('mycompany_sync_external_api', function($endpoint, $retry_attempt) {
// Trace timing
$start = microtime(true);
try {
\MyCompany\Sync\ExternalDataSync::execute_api_sync($endpoint, $retry_attempt);
$duration = microtime(true) - $start;
// Send custom metric
if (function_exists('newrelic_record_custom_event')) {
newrelic_record_custom_event('APISync', [
'endpoint' => $endpoint,
'duration_ms' => round($duration * 1000),
'retry_attempt' => $retry_attempt,
'status' => 'success',
]);
}
} catch (\Throwable $e) {
// Send error metric
if (function_exists('newrelic_notice_error')) {
newrelic_notice_error($e);
}
}
}, 10, 2);
Observability Checklist
- Log all API failures with timestamp and error code
- Track ActionScheduler queue depth (
SELECT COUNT(*) FROM {$wpdb->actionscheduler_actions}) - Monitor external API availability (status page polling)
- Alert when circuit breaker opens (API down)
- Measure cache hit rate (
(hits) / (hits + misses)) - Track TTFB percentiles before/after middleware migration
6. Migration Checklist
| Phase | Task | Verification |
|---|---|---|
| 1. Analysis | Audit codebase for wp_remote_get() in hooks | grep -r "wp_remote_get" . --include="*.php"finds 0 sync calls in init, wp_footer, etc. |
| 2. Queue Setup | Install ActionScheduler, create custom table | wp db query "SELECT * FROM wp_actionscheduler_actions LIMIT 1" works |
| 3. Job Definition | Write execute_api_sync(), register hook | Manual do_action('mycompany_sync_external_api', ...)executes without errors |
| 4. Enqueue Integration | Replace direct API calls with schedule_api_sync() | Page load now <10ms faster |
| 5. Testing | Simulate API failures (502, 429, timeout) | Retries respect backoff, circuit opens correctly |
| 6. Monitoring | Deploy New Relic instrumentation | Metrics dashboard shows sync latency, retry rates |
| 7. Gradual Rollout | Canary to 10% traffic → 50% → 100% | Monitor TTFB percentile improvements (target: >70% < 200ms) |
| 8. Cleanup | Remove legacy sync code, document patterns | Code review confirms no blocking sync calls remain |
7. Anti-Patterns to Avoid
❌ Blocking in Plugin Initialization
php// DON'T DO THIS
add_action('plugins_loaded', function() {
$data = wp_remote_get('https://api.example.com/config'); // Blocks init
});
❌ Synchronous REST API Endpoint
php// DON'T DO THIS
register_rest_route('myapp/v1', '/sync', [
'callback' => function() {
$response = wp_remote_get('https://api.example.com/data'); // Request hangs
return $response;
},
]);
❌ Direct Transient Fetch Without Fallback
php// DON'T DO THIS
function get_api_data() {
$cached = get_transient('api_data');
if (!$cached) {
// Blocks if API is slow
$cached = wp_remote_get('https://api.example.com/data');
set_transient('api_data', $cached, 3600);
}
return $cached;
}
✅ Correct Pattern
php// DO THIS
function get_api_data() {
$cached = get_transient('api_data');
if (!$cached) {
// Enqueue async, return stale or fallback
ExternalDataSync::schedule_api_sync('data');
return get_stale_data() ?? 'No data available yet';
}
return $cached;
}
Conclusion
Direct synchronous API calls in WordPress break at scale because they serialize external latency into every page request, exhausting PHP workers. The Middleware Queue Patterndecouples requests from I/O via ActionScheduler, enabling:
- 80-90% TTFB improvement
- Stable worker utilization (no starvation)
- Graceful degradation when APIs fail (cached fallback, circuit breaker)
- Observability (trace every sync, measure backoff effectiveness)
Implement this pattern before your store hits 50+ concurrent users. Otherwise, scaling becomes a capacity problem, not an architecture one.