Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save vladolaru/f3473dffab005616ae2073165be0b307 to your computer and use it in GitHub Desktop.

Select an option

Save vladolaru/f3473dffab005616ae2073165be0b307 to your computer and use it in GitHub Desktop.
Polling Backend Caching: Performance Analysis and Mitigation Strategy (WOOPRD-2691 follow-up)

Polling Backend Caching: Performance Analysis and Mitigation Strategy

Date: 2026-02-27 (updated 2026-02-28 — incorporated security, correctness, and operational findings from external critique) Context: Auto-refresh polling (WOOPRD-2691) sends a full REST request every 30 seconds per active list page. This analysis explores server-side caching strategies to minimize the backend cost of that polling.

Related:

  • .claude/docs/analysis/2026-02-27-list-auto-refresh-analysis.md — frontend analysis
  • .claude/docs/plans/2026-02-27-list-auto-refresh-plan.md — frontend implementation plan

1. The Problem

Every 30-second poll currently triggers the full request chain:

WooPayments entities (transactions, disputes, payouts):

Browser → CIAB v4 proxy → WooPayments v3 REST → Transact Platform → Stripe API
          (PHP)            (PHP)                  (remote HTTPS)     (remote)

WooCommerce entities (orders, customers):

Browser → WC/CIAB REST API → WordPress DB (HPOS for orders, wc_customer_lookup + users + order stats for customers)
          (PHP)             (SQL query)

For a merchant with the orders page open and doing nothing, we run a full database query + JSON serialization + HTTP response every 30 seconds. For WooPayments pages, we also make a remote HTTPS round-trip to the Transact Platform. In the common case — nothing has changed — all this work produces an identical response.

Scale

A single merchant with one tab open: 1 request / 30s = 2 req/min. Manageable. But consider:

  • Multiple merchants with multiple tabs across the same store
  • 5 list pages now have auto-refresh (orders, customers, transactions, disputes, payouts)
  • Window-focus gating limits to 1 active tab per browser, but multiple humans may use the same store concurrently
  • Garden/CIAB sites handle multiple stores on shared infrastructure

The polling cost scales linearly with concurrent admin users. Backend caching can collapse many identical requests into one actual query.


2. Infrastructure: Memcached on CIAB Sites

CIAB sites on Garden/WordPress.com run memcached as the persistent object cache backend. This fundamentally changes the calculus:

Without memcached With memcached
wp_cache_get/set lives in PHP memory, dies with the request wp_cache_get/set persists across requests, shared by all web workers
Transients stored in wp_options table (DB read on every check) Transients bypass the DB entirely, stored in memcached
wp_cache_get_last_changed('posts') reseeds from scratch every request wp_cache_get_last_changed('posts') returns the persistent timestamp
Cache reads = database queries Cache reads = memcached GET (~0.1-0.5ms)
No sharing between concurrent requests All concurrent requests share the same cache

Key implication: A cache read is ~100-1000x faster than a database query. A cache hit that returns "nothing changed" costs essentially nothing. Transient-based caching is free on CIAB infrastructure.

Transient behavior with memcached (WordPress core wp-includes/option.php:1453):

if ( wp_using_ext_object_cache() || wp_installing() ) {
    $value = wp_cache_get( $transient, 'transient' );  // memcached, no DB
}

3. The Two Domains of Data

The five auto-refreshing list pages split into two fundamentally different categories based on where the authoritative data lives and what change-detection signals are available.

Domain 1: Local Data (Orders, Customers)

Data lives in the WordPress database. We can cheaply detect changes because WordPress and WooCommerce fire hooks and update cache timestamps when data changes.

Change detection signals available:

Signal Source Updated When
wp_cache_get_last_changed('posts') WordPress core (wp-includes/post.php:7794) Any post/order create, update, or delete via clean_post_cache()
wp_cache_get_last_changed('users') WordPress core User create, update, or delete via clean_user_cache()
woocommerce_delete_shop_order_transients action WooCommerce (wc-order-functions.php:538) Order CRUD operations
WC_Cache_Helper::invalidate_cache_group('orders') WooCommerce Namespace-based invalidation, updates wc_orders_cache_prefix
OrdersVersionStringInvalidator hooks WooCommerce REST API Cache woocommerce_new_order, woocommerce_update_order, woocommerce_order_status_changed, etc.

Important nuance for customers: /wc/next/customers is not only user-profile data. It is built from WooCommerce Analytics (/wc-analytics/reports/customers) and includes order-derived fields (orders_count, total_spent, activity timestamps). A users-only version signal is insufficient because order activity can change the customer list without any clean_user_cache() event.

Bottom line: We can cheaply detect local changes, but customers need a composite signal (at minimum users + posts/orders) or a custom ciab_customers_version hook set.

Domain 2: Remote Data (Transactions, Disputes, Payouts)

Data lives on Stripe's infrastructure, proxied through the Transact Platform → WooPayments → CIAB. List endpoints fetch from Transact Platform on every request.

However, the Transact Platform sends webhooks to the store when things happen. WooPayments processes 18+ event types in class-wc-payments-webhook-processing-service.php. Two hooks fire on every webhook event:

  • woocommerce_payments_before_webhook_delivery — fires before processing, with ($event_type, $event_body)
  • woocommerce_payments_after_webhook_delivery — fires after processing, with ($event_type, $event_body)

Event types relevant to each list page:

List Page Relevant Webhook Events Local Side Effects
Transactions payment_intent.succeeded, payment_intent.payment_failed, payment_intent.canceled, payment_intent.amount_capturable_updated, charge.refunded, charge.refund.updated, charge.expired Order status/metadata updated via $order->save(), authorization caches cleared
Disputes charge.dispute.created, charge.dispute.updated, charge.dispute.closed, charge.dispute.funds_withdrawn, charge.dispute.funds_reinstated Order status updated, dispute count caches explicitly cleared via $this->database_cache->delete_dispute_caches(). CIAB's WooPayments_Next_Dispute_Webhook_Tracker also listens for all 5 dispute events (including charge.dispute.updated) and syncs _wcpay_dispute_status / _wcpay_dispute_id to order meta.
Payouts payout.created, payout.paid, payout.failed, payout.canceled, payout.updated, payout.reconciliation_completed WooPayments client doesn't process these (no case in switch), but the woocommerce_payments_after_webhook_delivery hook still fires for them

WooPayments already clears its own internal caches on webhooks (dispute counts, authorization summaries, account data). We hook into the same webhook delivery signal to invalidate our response caches.

Webhook reliability: WC_Payments_Webhook_Reliability_Service catches failed webhooks and replays them via ActionScheduler. This is not a fragile "fire and hope" mechanism.

Bottom line: For all three WooPayments entity types, webhooks provide a reliable "data has changed" signal — we can cache aggressively and invalidate when a webhook arrives. The Transact Platform forwards payout events to the store, and although the WooPayments client doesn't process them internally (no case in its switch statement), the woocommerce_payments_after_webhook_delivery hook fires for all received events — including payout events. This means CIAB can hook into payout webhooks for cache invalidation.


4. Strategy: Two-Tier Caching at the CIAB Proxy Layer

The CIAB proxy layer (woocommerce-payments-next/api/ and woocommerce-next/api/) is the right place for backend caching. It provides clean separation: the frontend polls at its own cadence, the proxy absorbs redundant requests, upstream APIs see reduced load.

Tier 1: Version-Stamped Cache for Local Entities (Orders, Customers)

Concept: Before running the expensive database query, check if the underlying data has changed since the last response was cached. If nothing changed, return the cached response instantly.

Poll request arrives at CIAB v4 endpoint
  ↓
Check current data version (memcached GET, ~0.1ms)
  ↓
Compare with version stamped on cached response
  ↓
Match? → Return cached response (skip DB query entirely)
No match? → Run full query, cache response with new version stamp

Version source options:

Approach Signal Precision Tradeoff
wp_cache_get_last_changed('posts') WordPress core Broad — any post type change invalidates Simple, no custom hooks needed
Custom ciab_orders version group Custom hooks on WC order lifecycle Precise — only order changes invalidate ~10 lines of hook registration

The broad approach works and is simplest — unnecessary cache misses from non-order post changes are infrequent and cheap (just one real query to repopulate the cache). The precise approach is a refinement if we see excessive misses.

Hooks for precise order-specific version:

add_action( 'woocommerce_new_order', 'ciab_bump_orders_version' );
add_action( 'woocommerce_update_order', 'ciab_bump_orders_version' );
add_action( 'woocommerce_trash_order', 'ciab_bump_orders_version' );
add_action( 'woocommerce_untrash_order', 'ciab_bump_orders_version' );
add_action( 'woocommerce_delete_order', 'ciab_bump_orders_version' );
add_action( 'woocommerce_order_status_changed', 'ciab_bump_orders_version' );

For customers: A robust baseline is a composite signal (wp_cache_get_last_changed('users') + wp_cache_get_last_changed('posts')). A more precise approach is a custom ciab_customers_version bumped by customer and order lifecycle hooks.

Note on WooCommerce's built-in REST API Cache: WooCommerce has a RestApiCache trait, but it is experimental, disabled by default, and does not cover Orders or Customers endpoints (only Products, Taxes, and Data endpoints). Tier 1 caching must be implemented by us. See Section 7 for details.

Tier 2: Webhook-Invalidated Cache for Remote Entities (Transactions, Disputes, Payouts)

Concept: Cache the upstream API response with an entity-appropriate TTL. Invalidate immediately when a relevant webhook arrives from the Transact Platform. Between webhooks, every poll is a cache hit.

TTL values by entity:

Entity TTL Rationale
Transactions 5 minutes Webhook-invalidated. Frequent changes (payments, refunds). Short TTL as safety net.
Disputes 30 minutes Webhook-invalidated. Rare events (a few per week for typical stores). Long TTL is safe because webhooks handle real changes.
Payouts 30 minutes Webhook-invalidated (6 payout events forwarded from Transact Platform). Very rare changes (daily/weekly Stripe batches).

This is a fundamental improvement over blind short-TTL caching: instead of guessing when data might have changed, we know when it changed because the Transact Platform tells us via webhooks.

Normal poll (nothing changed):
  Browser → CIAB v4 proxy → memcached GET → HIT → return cached response
  Cost: ~0.5ms (memcached only, no upstream call)

Webhook arrives (something changed):
  Transact Platform → /wc/v3/payments/webhook → WooPayments processes event
    → fires woocommerce_payments_after_webhook_delivery
    → CIAB hook bumps version key (memcached SET, ~0.1ms)

Next poll after webhook:
  Browser → CIAB v4 proxy → memcached GET → MISS → upstream fetch → cache response
  Cost: ~200-500ms (one upstream call, then back to cache hits)

Webhook → cache invalidation mapping:

$invalidation_map = array(
    // Transaction list cache.
    'payment_intent.succeeded'                     => 'payments/transactions',
    'payment_intent.payment_failed'                => 'payments/transactions',
    'payment_intent.canceled'                      => 'payments/transactions',
    'payment_intent.amount_capturable_updated'     => 'payments/transactions',
    'charge.refunded'                              => 'payments/transactions',
    'charge.refund.updated'                        => 'payments/transactions',
    'charge.expired'                               => 'payments/transactions',

    // Dispute list cache.
    'charge.dispute.created'                       => 'payments/disputes',
    'charge.dispute.updated'                       => 'payments/disputes',
    'charge.dispute.closed'                        => 'payments/disputes',
    'charge.dispute.funds_withdrawn'               => 'payments/disputes',
    'charge.dispute.funds_reinstated'              => 'payments/disputes',

    // Payout list cache.
    'payout.created'                               => 'payments/deposits',
    'payout.paid'                                  => 'payments/deposits',
    'payout.failed'                                => 'payments/deposits',
    'payout.canceled'                              => 'payments/deposits',
    'payout.updated'                               => 'payments/deposits',
    'payout.reconciliation_completed'              => 'payments/deposits',
);

Invalidation mechanism: Namespace-based version stamping (same pattern as WooCommerce's CacheNameSpaceTrait). When a webhook arrives, we update a version key in memcached. All cache keys include this version as a prefix, so updating the version orphans all old entries instantly — no need to enumerate and delete individual keys.

// Webhook handler bumps version:
wp_cache_set( 'ciab_payments/transactions_version', microtime(), 'ciab_woopayments_rest' );

// Cache key includes version prefix (with normalized params):
$version   = wp_cache_get( 'ciab_' . $this->rest_base . '_version', $cache_group );
$params    = $request->get_params();
// Strip non-data params (see Section 9 for full classification).
unset( $params['_freshness'], $params['_locale'], $params['_envelope'], $params['_pretty'], $params['_jsonp'], $params['rest_route'] );
ksort( $params );                // Normalize insertion order.
$cache_key = $this->rest_base . '_v' . CIAB_CACHE_SCHEMA_VERSION . '_' . $version . '_' . md5( wp_json_encode( $params ) );

Payouts — webhook-invalidated with long TTL: The Transact Platform forwards 6 payout event types (payout.created, payout.paid, payout.failed, payout.canceled, payout.updated, payout.reconciliation_completed) to the store. The WooPayments client doesn't process them internally, but woocommerce_payments_after_webhook_delivery fires for all received events. We add payout events to the invalidation map just like transactions and disputes. The 30-minute TTL acts as a safety net, and _freshness=fresh page-load bypass (see Section 5) auto-heals on navigation.

Why webhook-invalidated beats short-TTL:

Aspect Short-TTL (20s) Webhook-Invalidated (5-30min TTL)
Cache hit rate (single user) ~33% ~95-99%
Upstream API calls per hour ~120 per entity per query ~0-10 per entity
Data freshness after change Up to 50s (20s TTL + 30s poll) ~30s (webhook + next poll)
Data freshness when nothing changed Refetches anyway Stays cached

The TTL acts as a safety net: even if a webhook is delayed or lost, the cache expires and the next poll fetches fresh data. The _freshness=fresh page-load bypass (Section 5) provides an additional auto-heal mechanism — navigating to a list page always fetches fresh data and repopulates the cache.

Correctness Guardrails (Required)

To keep cache behavior safe under real production conditions, two guardrails are required:

  1. Do not cache degraded fallback responses. The WooPayments list base controller currently converts non-200 upstream responses into an empty 200 response for resilience. Those responses should be returned, but not cached, otherwise transient upstream failures can be amplified into sustained false-empty states.
  2. Add stampede protection on cache MISS. After a version bump, many concurrent requests can MISS at once and all hit upstream. Use a short-lived per-key lock (wp_cache_add lock key + timeout) or stale-while-revalidate behavior so one request repopulates while others reuse stale/briefly wait.

5. Frontend Integration: Transparent Caching + Page-Load Cache Bypass

The caching is almost entirely transparent to the frontend. Same endpoints, no protocol changes. The one frontend addition: route loaders pass _freshness=fresh on page navigation so that every page load fetches real data and repopulates the cache. Polling requests omit this param and use the cache normally.

The _freshness=fresh Mechanism

Problem: If a webhook is delayed or the cache fails to invalidate, the user sees stale data — and a browser refresh doesn't help because the cached response is still served.

Solution: Page loads bypass the cache. Polling uses the cache.

Page navigation / browser refresh:
  core-data fetches with ?_freshness=fresh → backend SKIPS cache → fresh data → repopulates cache

Subsequent 30s polls:
  core-data fetches WITHOUT _freshness → backend USES cache → fast cached response

Backend behavior:

// In the caching layer (get_items):
$is_fresh = 'fresh' === $request->get_param( '_freshness' );
if ( $is_fresh ) {
    // Skip cache lookup, fall through to real query.
    // Still cache the response for subsequent polls (under the poll-style key WITHOUT _freshness).
}

// Cache key generation — strip non-data params so fresh and poll requests share the same cache entry:
$params = $request->get_params();
unset( $params['_freshness'], $params['_locale'], $params['_envelope'], $params['_pretty'], $params['_jsonp'], $params['rest_route'] );
ksort( $params );
$cache_key = $rest_base . '_v' . CIAB_CACHE_SCHEMA_VERSION . '_' . $version . '_' . md5( wp_json_encode( $params ) );

Frontend behavior — route loaders add _freshness:

// In each list route's loader:
loader: async () => {
    await resolveSelect( coreStore ).getEntityRecords( kind, name, {
        ...query,
        _freshness: 'fresh',  // Bypass backend cache on page load
    } );
}

useAutoRefresh does NOT include _freshness — polls use the cache normally.

This creates a self-healing cycle:

  1. User navigates to page → _freshness=fresh → always gets real data → cache repopulated
  2. Polls fire every 30s → cache HIT → fast response
  3. Webhook arrives → cache invalidated → next poll is cache MISS → cache repopulated
  4. If webhook is lost → cache serves stale data for polls → but next page navigation auto-heals

End-to-End Architecture

┌──────────────────────────────────────────────────────────────┐
│  FRONTEND                                                     │
│                                                               │
│  Route loader (page navigation):                              │
│    getEntityRecords({ ...query, _freshness: 'fresh' })        │
│    ↓                                                          │
│  useAutoRefresh (every 30s, tab focused):                     │
│    invalidateResolution → getEntityRecords({ ...query })      │
│    ↓  (no _freshness param — uses cache)                      │
│  response → receiveEntityRecords → store → React re-render    │
└──────────────────────────┬───────────────────────────────────┘
                           │ HTTP GET (same endpoints)
┌──────────────────────────▼───────────────────────────────────┐
│  CIAB PROXY LAYER (caching logic)                             │
│                                                               │
│  _freshness=fresh? → SKIP cache, run real query/fetch,        │
│                      cache result for subsequent polls         │
│                                                               │
│  Orders/Customers (Tier 1):                                   │
│    Check version signal (orders: posts/custom hooks;          │
│    customers: users+posts or custom hooks)                    │
│    → match? return cached response                            │
│    → mismatch? run real query, cache with version stamp       │
│                                                               │
│  Transactions (Tier 2, 5min TTL):                             │
│    Check version-prefixed cache key in memcached              │
│    → HIT? return cached response                              │
│    → MISS? fetch from upstream, cache with current version    │
│                                                               │
│  Disputes (Tier 2, 30min TTL):                                │
│    Same as transactions but with longer safety-net TTL        │
│                                                               │
│  Payouts (Tier 2, 30min TTL):                                 │
│    Check version-prefixed cache key in memcached              │
│    → HIT? return cached response                              │
│    → MISS? fetch from upstream, cache with current version    │
└──────────────────────────┬───────────────────────────────────┘
                           │ (only on cache MISS)
┌──────────────────────────▼───────────────────────────────────┐
│  CACHE INVALIDATION TRIGGERS                                  │
│                                                               │
│  Orders:                                                      │
│    woocommerce_new_order, woocommerce_update_order,           │
│    woocommerce_order_status_changed → bump version            │
│                                                               │
│  Transactions:                                                │
│    payment_intent.succeeded/failed/canceled,                  │
│    charge.refunded/expired → webhook → bump version           │
│                                                               │
│  Disputes:                                                    │
│    charge.dispute.created/closed,                             │
│    funds_withdrawn/reinstated → webhook → bump version        │
│                                                               │
│  Payouts:                                                     │
│    payout.created/paid/failed/canceled/updated                │
│    → webhook → bump version                                   │
│                                                               │
│  CIAB UI mutations:                                           │
│    dispute response, order actions → bump version directly    │
│                                                               │
│  Page navigation:                                             │
│    _freshness=fresh → bypasses cache → repopulates on fetch   │
└──────────────────────────────────────────────────────────────┘

What Happens When Nothing Changed

  1. Frontend fires invalidateResolution → core-data makes GET request (same URL, no _freshness)
  2. PHP boots WordPress (~30-50ms), reaches CIAB proxy endpoint
  3. Proxy checks memcached: version matches → returns cached response (~0.5ms)
  4. Frontend receives identical JSON, core-data processes it as normal
  5. React virtual DOM diff finds no changes → no DOM update

The cached response is the heartbeat. When nothing changed, the backend returns it instantly. The frontend doesn't know or care — it gets the same response shape, same headers, same data.

Why Not a Dedicated "Has Changed?" Endpoint

We considered having the frontend check a lightweight endpoint first and only fetch full data on change. Rejected because:

  • PHP bootstrap cost dominates (~30-50ms) regardless of response size — a 50-byte "unchanged" response costs the same server time as a 50KB full response
  • Doubles HTTP requests when data has changed (check + fetch)
  • Adds frontend complexity (two-phase polling, version tracking, conditional fetch)
  • The backend cache already eliminates the expensive work (DB queries, upstream API calls)
  • The remaining cost (PHP bootstrap + memcached check) is the same whether we return "unchanged" or the cached full response

Frontend Processing of Identical Responses

When data hasn't changed, core-data still processes the identical response: normalize, merge into store, trigger subscribers. React components re-evaluate selectors and run virtual DOM diff. This is ~5-15ms of wasted work per cycle.

This is not worth optimizing now. The real cost we're eliminating is the backend work (200-500ms for upstream API calls, 50-200ms for DB queries). The 5-15ms of frontend processing is negligible by comparison. If it becomes an issue later, a version-header protocol (backend sends X-CIAB-Data-Version, frontend tracks it) is a clean upgrade path.


6. Cache Explosion: Why It's Not a Risk

Each unique query (filter + sort + pagination combination) gets its own cache entry. The concern: does this create too many entries?

Active vs Stale Entries

Polling only refreshes the current view's query. useAutoRefresh invalidates the resolution for the one query the user is looking at — not every filter combination. At any moment, one admin user on the transactions page has exactly 1 active cache entry for that entity.

When they change filters or paginate:

  • New query → new cache key → cache MISS → upstream fetch → new entry cached
  • Old entry sits in memcached until TTL or LRU eviction — inert, not being polled

Realistic Cache Inventory

Scenario Active entries Stale entries (expiring) Memory
1 admin, 1 page open 1 0-5 (from filter exploration) ~50-300KB
1 admin, 3 entity pages open 3 0-15 ~150KB-900KB
5 admins, mixed pages 5-15 10-50 ~0.5-3MB
10 admins, heavy filter use 10-30 30-100 ~1.5-7MB

Memcached instances on Garden are typically hundreds of MB to GB. Even the worst case is noise.

Version Bumps Orphan Stale Entries En Masse

When a webhook arrives and bumps the version:

Before webhook:  version = v_0.12345678
  entries: transactions_v_0.12345678_md5({page:1,...})
           transactions_v_0.12345678_md5({page:2,...})
           transactions_v_0.12345678_md5({status:succeeded,...})

After webhook:   version = v_0.98765432
  New lookups use: transactions_v_0.98765432_md5({page:1,...})
  Old keys are unreachable — can't be found via the new version prefix
  They sit in memcached until TTL expires or LRU evicts them

No need to enumerate and delete old entries. The namespace version change makes them invisible in one operation.

Invalidation Granularity

Invalidation is per entity type, not per query. When a charge.dispute.created webhook arrives, ALL dispute query variations get invalidated — including a "closed disputes" filter that the new dispute doesn't affect.

This is conservative but correct. The cost of one unnecessary cache miss (a single upstream fetch) is trivial compared to the complexity of per-filter invalidation logic. And webhooks are infrequent — a few per hour for a typical store.


7. Implementation Architecture

Principle: Minimal Footprint

The caching layer touches as few files as possible. No new classes, no traits, no middleware abstractions. Just conditional logic added to existing methods, and one webhook handler function.

Tier 2: WooPayments Entities — Base Class Method

All 6 WooPayments list controllers extend WooPayments_Next_List_REST_Controller. The get_items() method (lines 283-352) is the single chokepoint where caching intercepts.

Approach: Add cache check/store logic to the base class get_items(). Subclasses opt in by overriding get_cache_ttl() (returns 0 by default = no caching).

// Schema version — bump when cached response format changes (field added/removed/renamed).
// Ensures stale-format entries from pre-deploy are never served post-deploy.
const CIAB_CACHE_SCHEMA_VERSION = 1;

// Base class adds:
protected function get_cache_ttl(): int {
    return 0;  // Subclasses override to opt in.
}

// In get_items():
$ttl       = $this->get_cache_ttl();
$is_fresh  = 'fresh' === $request->get_param( '_freshness' );

// Feature flag — disable all polling cache without a deploy.
if ( defined( 'CIAB_DISABLE_POLLING_CACHE' ) && CIAB_DISABLE_POLLING_CACHE ) {
    $ttl = 0;
}

if ( $ttl > 0 && ! $is_fresh ) {
    // Build cache key — strip non-data params, normalize param order.
    // See Section 9 for full parameter classification rationale.
    $params = $request->get_params();
    unset( $params['_freshness'], $params['_locale'], $params['_envelope'], $params['_pretty'], $params['_jsonp'], $params['rest_route'] );
    ksort( $params );
    $version   = wp_cache_get( 'ciab_' . $this->rest_base . '_version', 'ciab_woopayments_rest' );
    $cache_key = $this->rest_base . '_v' . CIAB_CACHE_SCHEMA_VERSION . '_' . $version . '_' . md5( wp_json_encode( $params ) );
    $cached    = wp_cache_get( $cache_key, 'ciab_woopayments_rest' );
    if ( false !== $cached ) {
        // Return cached WP_REST_Response with stored headers.
        return $cached;
    }

    // Stampede protection: one request populates, others wait briefly or use stale.
    $lock_key = 'lock_' . $cache_key;
    $has_lock = wp_cache_add( $lock_key, 1, 'ciab_woopayments_rest', 10 );
}
// ... existing proxy logic (unchanged) ...
if ( $ttl > 0 ) {
    // Do not cache degraded fallback responses produced from upstream non-200.
    // Return them to the client, but skip cache store.

    // Store response (data + headers) in cache with version prefix + TTL.
    // Use same key derivation as above (strip _freshness, ksort).
}

Subclass overrides (one line each):

Controller get_cache_ttl() Rationale
Transactions_REST_Controller 5 * MINUTE_IN_SECONDS Frequent changes, webhook-invalidated
Disputes_REST_Controller 30 * MINUTE_IN_SECONDS Rare events, webhook-invalidated
Deposits_REST_Controller 30 * MINUTE_IN_SECONDS Rare events, webhook-invalidated
Fraud Outcomes, Authorizations, Readers 0 (default) Not polled, no caching needed

Webhook handler — single function in woocommerce-payments-next.php:

add_action( 'woocommerce_payments_after_webhook_delivery', 'woopayments_next_invalidate_list_caches', 10, 2 );

function woopayments_next_invalidate_list_caches( $event_type, $event_body ) {
    $invalidation_map = array( /* event → rest_base mapping */ );
    $rest_base = $invalidation_map[ $event_type ] ?? null;
    if ( null === $rest_base ) { return; }
    wp_cache_set( 'ciab_' . $rest_base . '_version', microtime(), 'ciab_woopayments_rest' );
}

Files touched:

File Change ~Lines
class-woopayments-list-rest-controller.php Cache logic in get_items(), get_cache_ttl(), _freshness param ~40
class-woopayments-transactions-rest-controller.php Override get_cache_ttl() ~5
class-woopayments-disputes-rest-controller.php Override get_cache_ttl() ~5
class-woopayments-deposits-rest-controller.php Override get_cache_ttl() ~5
woocommerce-payments-next.php Webhook invalidation handler ~30

Total Tier 2: 5 files, ~85 lines.

Operational requirements for Tier 2:

  • Add an explicit branch to skip cache store for degraded fallback responses (empty list generated from upstream non-200).
  • Add per-key MISS lock to prevent request fan-out after invalidation/version bump.

Tier 1: Orders — rest_pre_dispatch Short-Circuit

CIAB does not own the orders list endpoint. The frontend calls /wc/v4/orders (WooCommerce core's controller), and CIAB's Orders\Controller hooks into rest_post_dispatch to enrich the response with customer_record_id and refunds.

Problem: We can't add caching inside get_items() because we don't control it. The orders query runs inside WC core's REST controller.

Approach: Use the rest_pre_dispatch filter to short-circuit the request before WC processes it. This is a well-documented WordPress pattern — if rest_pre_dispatch returns a WP_REST_Response, the entire request pipeline (including WC's query) is skipped.

Security: rest_pre_dispatch bypasses permission callbacks. WordPress runs permission_callback AFTER rest_pre_dispatch. If we return a cached response here, the route's get_items_permissions_check() never runs. The cache handler MUST explicitly verify capabilities before returning cached data. Without this, any authenticated user hitting /wc/v4/orders directly would receive cached order data regardless of their role. This is a hard requirement, not a future consideration.

Important: rest_post_dispatch still fires on short-circuited responses. WordPress calls rest_post_dispatch in serve_request() AFTER dispatch() returns, regardless of whether the response came from rest_pre_dispatch or normal route handling. This means the existing batch_enrich_orders hook (which adds customer_record_id and refunds) will re-run on cached responses. Since the cached response already includes enriched data, the enricher must detect and skip already-enriched responses to avoid redundant DB queries.

add_filter( 'rest_pre_dispatch', 'woocommerce_next_cache_orders_list', 10, 3 );

function woocommerce_next_cache_orders_list( $result, $server, $request ) {
    // Feature flag — disable all polling cache without a deploy.
    if ( defined( 'CIAB_DISABLE_POLLING_CACHE' ) && CIAB_DISABLE_POLLING_CACHE ) {
        return $result;
    }

    // Only intercept GET /wc/v4/orders (list, not single-item).
    if ( 'GET' !== $request->get_method() || '/wc/v4/orders' !== $request->get_route() ) {
        return $result;
    }

    // SECURITY: rest_pre_dispatch bypasses route permission_callback.
    // Explicitly verify the requesting user has the required capability.
    if ( ! current_user_can( 'edit_shop_orders' ) ) {
        return $result;  // Let WordPress handle the 403 via normal permission flow.
    }

    if ( 'fresh' === $request->get_param( '_freshness' ) ) {
        return $result;  // Bypass cache on page load.
    }

    $version   = wp_cache_get_last_changed( 'posts' );
    $params    = $request->get_params();
    // Strip non-data params — see Section 9 for full classification.
    unset( $params['_freshness'], $params['_locale'], $params['_envelope'], $params['_pretty'], $params['_jsonp'], $params['rest_route'] );
    ksort( $params );
    $cache_key = 'ciab_orders_v' . CIAB_CACHE_SCHEMA_VERSION . '_' . $version . '_' . md5( wp_json_encode( $params ) );
    $cached    = wp_cache_get( $cache_key, 'ciab_wc_rest' );

    if ( false !== $cached ) {
        $response = rest_ensure_response( $cached['data'] );
        // Restore pagination headers — DataViews requires these for pagination controls.
        foreach ( $cached['headers'] as $header => $value ) {
            $response->header( $header, $value );
        }
        $response->header( 'X-CIAB-Cache', 'HIT' );
        return $response;
    }

    // Cache miss — let request proceed. Store result in rest_post_dispatch.
    return $result;
}

The companion rest_post_dispatch handler stores the result on cache miss. It must run after the existing batch_enrich_orders enricher (use a later priority, e.g., 20 vs the enricher's 10) so the cached response includes customer_record_id and refunds. Cached data includes both $response->get_data() and headers (X-WP-Total, X-WP-TotalPages).

Preventing double enrichment on cache hits: When rest_pre_dispatch returns a cached response, rest_post_dispatch still fires. The batch_enrich_orders hook runs again on the already-enriched data and would re-query customer records and refunds — partially defeating the cache. Two options:

  1. Guard the enricher — check for X-CIAB-Cache: HIT header in batch_enrich_orders and skip enrichment:
    public function batch_enrich_orders( $response, $server, $request ) {
        if ( 'HIT' === $response->get_headers()['X-CIAB-Cache'] ?? '' ) {
            return $response;  // Already enriched from cache.
        }
        // ... existing enrichment logic ...
    }
  2. Accept reduced savings — the enrichment queries (~5-10ms) are much cheaper than the main WP_Query (~50-200ms). The cache still eliminates the dominant cost. Document the tradeoff.

Option 1 is cleaner and recommended — one guard clause, ~3 lines.

Version source: wp_cache_get_last_changed('posts') — updated by WordPress core on any post/order create, update, delete via clean_post_cache(). Verified: HPOS (High Performance Order Storage) orders DO trigger this via the inherited clear_caches()clean_post_cache() chain, even though HPOS stores orders in wc_orders instead of wp_posts. Broad (includes non-order post types — blog posts, products, pages also bump this) but simple and zero custom hooks. A more precise option: custom ciab_orders_version bumped only by WooCommerce order hooks. Recommendation: Start with the broad signal but add cache hit/miss logging from day 1 to measure false-invalidation rate and decide if custom hooks are needed.

Files touched:

File Change ~Lines
woocommerce-next.php (or orders bootstrap) rest_pre_dispatch + rest_post_dispatch cache handlers ~55
orders/Controller.php Guard clause in batch_enrich_orders to skip on cache HIT ~3

Total Tier 1 Orders: 2 files, ~58 lines.

Tier 1: Customers — Direct Method Caching

CIAB owns the customers list endpoint (/wc/next/customers). The Customers\Controller::get_items() method proxies to /wc-analytics/reports/customers and maps the response.

Approach: Same as WooPayments — add cache check/store directly in get_items().

Version source (correctness-critical):

  • Do not use users-only versioning. It misses order-driven analytics changes (orders_count, total_spent, activity dates).
  • Minimum viable signal: combine users + posts/order signals, e.g. users_last_changed:posts_last_changed.
  • More precise option: custom ciab_customers_version bumped by customer lifecycle + order lifecycle hooks.

Recommended baseline (ship-first):

$users_last_changed = wp_cache_get_last_changed( 'users' );
$posts_last_changed = wp_cache_get_last_changed( 'posts' );

// Customer list math depends on analytics excluded statuses (option + filters).
$excluded_statuses = apply_filters(
	'woocommerce_analytics_excluded_order_statuses',
	(array) get_option( 'woocommerce_excluded_report_order_statuses', array() )
);
$excluded_statuses_hash = md5( wp_json_encode( array_values( $excluded_statuses ) ) );

$customers_version = md5(
	$users_last_changed . '|' . $posts_last_changed . '|' . $excluded_statuses_hash
);

This composite signal is conservative and correct. If false invalidation is high in production metrics, move to a custom ciab_customers_version with explicit hook bumps.

Files touched:

File Change ~Lines
customers/Controller.php Cache logic in get_items() + composite version + required header restore (Allow) ~40

Total Tier 1 Customers: 1 file, ~40 lines.

Frontend: _freshness in Route Loaders

Each list page's route loader adds _freshness: 'fresh' to the query so page navigations bypass the cache:

loader: async () => {
    await resolveSelect( coreStore ).getEntityRecords( kind, name, {
        ...query,
        _freshness: 'fresh',
    } );
}

Files touched: 5 route loader files (orders, customers, transactions, disputes, payouts), ~3 lines each.

Total Across All Tiers

Component Files Lines
Tier 2: WooPayments base + 3 subclasses + webhook handler 5 ~85
Tier 1: Orders (rest_pre_dispatch cache + enrichment guard) 2 ~58
Tier 1: Customers (direct method cache + composite version) 1 ~40
Frontend: _freshness in route loaders 5 ~15
Total (core runtime changes) 13 ~200-230

No new files or major abstractions expected. This estimate excludes test additions, which are non-trivial and should be planned explicitly.


8. Alternatives Considered

Lightweight "Has Changed?" Endpoint — Rejected

Separate endpoint returning { changed: true/false }. Frontend checks first, only fetches full data on change.

  • PHP bootstrap cost (~30-50ms) is the same regardless of response size
  • Doubles HTTP requests when data changed
  • Adds frontend complexity
  • Backend cache already eliminates the expensive work

HTTP-Level Caching (ETag / 304) — Rejected

Add ETag headers to responses, clients send If-None-Match.

  • WordPress REST API has zero existing ETag support
  • Server must still compute full response to generate ETag — no query savings
  • core-data / @wordpress/api-fetch doesn't send conditional request headers
  • 304 responses would confuse core-data's resolution tracking

WooCommerce REST API Caching Feature — Does Not Apply

WooCommerce has a built-in response cache (RestApiCache trait) with VersionStringGenerator-based invalidation. Investigated and found it does not help us:

  • Experimental feature (is_experimental: true), disabled by default, not production-ready
  • Only covers 5 endpoints: Products, Taxes, Data Countries/Continents/Currencies
  • Does NOT cover Orders or Customers — no RestApiCache trait on these controllers, and no CustomerVersionStringInvalidator exists
  • Does NOT apply to WooPayments proxy endpoints
  • No evidence of enablement on CIAB/Garden sites
  • Even if enabled, orders and customers would still not be cached

Conclusion: Tier 1 caching for orders/customers must be implemented by us. There is no free ride from WC core.

Short-TTL Only (No Webhook Invalidation) — Superseded

Blind 20s TTL for all WooPayments entities. Achieves only ~33% hit rate. Webhook-invalidated approach achieves ~95-99% for all three entity types (transactions, disputes, payouts). All receive webhooks from the Transact Platform.


9. Cache Key Design

$params = $request->get_params();

// Strip non-data params that don't affect response content.
// See "Parameter classification" below for the full rationale.
unset(
    $params['_freshness'],   // CIAB cache-control signal — must be stripped so fresh and poll requests share a key.
    $params['_locale'],      // Always 'user' (auto-added by @wordpress/api-fetch). WC list responses contain
                             // machine-readable values (status slugs, amounts, ISO dates) — no i18n strings.
    $params['_envelope'],    // Response wrapper format only ({body, status, headers}). Not used by core-data.
    $params['_pretty'],      // JSON_PRETTY_PRINT flag — whitespace only.
    $params['_jsonp'],       // JSONP callback wrapper — same data, different encoding. Not used by core-data.
    $params['rest_route']    // Routing metadata. Stripped by WP when pretty permalinks are enabled (Garden always has them),
                             // but strip explicitly as a safety net.
);

ksort( $params );  // Normalize key order — PHP arrays preserve insertion order, which varies by request.
$cache_key = $rest_base . '_v' . CIAB_CACHE_SCHEMA_VERSION . '_' . $version . '_' . md5( wp_json_encode( $params ) );

Components:

  • $rest_base — entity type (payments/transactions, payments/disputes, etc.)
  • CIAB_CACHE_SCHEMA_VERSION — static integer, bumped when cached response format changes (fields added/removed/renamed). Ensures stale-format entries from pre-deploy are never served post-deploy.
  • $version — namespace version (bumped by webhook or WC hooks), orphans old entries on change
  • md5(params) — captures all data-affecting query params: page, per_page, orderby, order, search, context, _fields, _embed, all filter params

Parameter Classification

$request->get_params() merges GET query params, URL regex captures, and registered argument defaults. All of these appear in the params array and would be included in the cache key hash unless explicitly stripped.

Stripped (non-data params — don't affect which records are returned or their field values):

Param Source Why Stripped
_freshness CIAB custom Cache-control signal. Fresh and poll requests must share the same key for the auto-heal mechanism to work.
_locale @wordpress/api-fetch middleware Auto-added as user to every request. WC list responses are machine-readable (status slugs, numeric amounts, ISO dates) — no translatable strings. Value is constant across all requests, so stripping has no effect on key uniqueness.
_envelope WordPress REST framework Wraps response in {body, status, headers}. Same data, different wrapper. Not used by core-data.
_pretty WordPress REST framework Adds JSON_PRETTY_PRINT flag. Whitespace-only difference.
_jsonp WordPress REST framework JSONP callback encoding. Same data. Not used by core-data.
rest_route WordPress (no pretty permalinks) Routing metadata stripped by get_params() when permalinks are enabled. Garden always has them, but strip explicitly as a safety net.

Kept (data-affecting params — different values = different response):

Param Why Kept
context Controls field visibility (view vs edit vs embed). WP_REST_Controller::get_collection_params() registers this with a default. Different contexts expose different field sets. For WooPayments custom controllers this is a no-op, but for WC orders context=edit returns raw field renditions. Keep for correctness.
_fields Sparse fieldset — controls which fields the server includes. Customers list uses this for performance (skip WC_Customer instantiation when only lightweight fields requested). Different _fields = genuinely different response content.
_embed Embeds related resources. Not currently used by the cached list endpoints, but different _embed values produce different nested data. Keep for forward-compatibility.
page, per_page, orderby, order, search Standard collection params. Obviously affect which records are returned and in what order.
All filter params (status, date ranges, amounts, etc.) Domain-specific filters from subclass get_filter_param_map().

Not applicable (never appear in GET list requests):

Param Why N/A
_method HTTP method override for browsers without PUT/PATCH/DELETE. Only relevant for mutation requests.
force Controls hard-delete vs soft-delete. Only relevant for DELETE requests.

$request->get_params() preserves PHP array insertion order. Two requests with the same params in different order (e.g., ?page=1&per_page=25 vs ?per_page=25&page=1) produce different JSON strings and different MD5 hashes without ksort().

Same view/filters across users share the cache (for current list endpoints). For these list endpoints, data is role-gated and effectively shared between eligible users, so user ID is not included in the key. However, the rest_pre_dispatch cache handler for orders MUST explicitly verify current_user_can('edit_shop_orders') before returning cached data, because rest_pre_dispatch bypasses the route's permission_callback entirely (see Section 7, Tier 1 Orders). WooPayments Tier 2 caching happens inside get_items(), which runs after permission checks — no additional capability gate is needed there. If per-user data shaping is introduced later, include get_current_user_id() in the key.

What gets cached: The entire normalized response — item array, total count, total pages, and required response headers. At minimum: X-WP-Total, X-WP-TotalPages, and for customers list flows, Allow (used by middleware to pre-resolve canUser). Approximately 30-50KB per entry (20-25 items × 1-2KB per item JSON).


10. Risks and Mitigations

Risk 1: Stale Data After Mutation

Orders (version-based): Low risk — order creation fires woocommerce_new_orderclean_post_cache() → version changes → next poll gets fresh data.

Customers (version-based): Low risk only if using composite invalidation (users + posts/orders) or custom customer+order hooks. Users-only invalidation is not sufficient.

Transactions/Disputes (webhook-invalidated): Low risk — webhook arrives within seconds of the Stripe event, cache version bumps, next poll (up to 30s later) fetches fresh data. Total latency ≈ 5-35 seconds, comparable to current no-cache behavior.

CIAB UI mutations (e.g., dispute response): Mutation handler bumps the cache version directly. Same defense-in-depth pattern as refreshDisputeBadge().

Payouts (webhook-invalidated, 30min TTL): Payout webhooks from the Transact Platform invalidate the cache. Between webhooks, the 30-minute TTL acts as safety net. Navigating to the payouts page (_freshness=fresh) always fetches real data. Payout data changes infrequently (daily/weekly Stripe batches), so staleness risk is minimal.

Risk 2: No External Object Cache (No Memcached)

The entire caching strategy uses wp_cache_get/set. What happens when memcached (or any external object cache) is not available?

WordPress default object cache behavior: Without an external object cache, WP_Object_Cache stores data in a PHP array (private $cache = array() in wp-includes/class-wp-object-cache.php). This array is created when PHP boots and dies when the PHP process ends. Between two separate HTTP requests, the cache is completely empty. TTL parameters on wp_cache_set are ignored — the cache doesn't outlive the request.

Impact on each caching tier:

Mechanism With memcached Without memcached
Version check (wp_cache_get_last_changed) Returns persistent timestamp from memcached Auto-seeds with new microtime() every request → version is always "new" → comparison always fails
Cached response (wp_cache_get($cache_key)) Returns cached response from memcached Always returns false (empty per-request cache)
Webhook version bump (wp_cache_set) Updates persistent version in memcached, visible to all subsequent requests Sets version in per-request array that dies immediately — invisible to other requests
Net result Full caching benefit (95-99% hit rate) Complete no-op — every request falls through to real query

Without memcached, the code path is:

  1. Check cache → miss (empty per-request cache)
  2. Do real work (DB query or upstream API call) — identical to today
  3. Cache result (into per-request array that's discarded immediately)
  4. Return response

This is identical to current behavior with ~1µs of overhead from the failed wp_cache_get calls. Zero harm, zero benefit.

Alternative considered: Transients instead of wp_cache_*

Transients persist without memcached via the wp_options database table. Would they be better?

Aspect wp_cache_* without memcached Transients without memcached
Read cost ~0 (in-memory miss) ~1-5ms (DB query on wp_options)
Write cost ~0 (in-memory set, discarded) ~1-10ms (DB INSERT into wp_options)
Persistence None (per-request only) Yes (survives across requests)
Benefit for orders/customers None Marginal — replaces one DB query (~50-200ms) with a different DB query (~1-5ms)
Benefit for WooPayments None Moderate — replaces upstream API call (~200-500ms) with DB query (~1-5ms)
Version-based invalidation Doesn't work (version reseeds per request) Would need transient-based version keys too — adds more DB reads/writes
Side effects None wp_options table bloat from transient rows, write amplification on cache miss, lazy expiry cleanup

Transients would provide some benefit for WooPayments entities (avoiding upstream calls), but the version-stamped invalidation pattern doesn't translate cleanly — version keys would also need to be transients, doubling the DB reads per cache check. And for orders/customers, we'd be trading one DB query for another.

Decision: Use wp_cache_* only. Reasoning:

  1. CIAB production always has memcached. That's where the caching delivers value and where polling load matters.
  2. Graceful degradation is perfect. Without memcached, the cache is a no-op — zero benefit but also zero harm, no DB writes, no wp_options bloat, no side effects.
  3. Transients add complexity for marginal benefit. The version-invalidation pattern would need two code paths (one for memcached, one for DB-backed transients) or a redesign of the invalidation mechanism.
  4. Local dev doesn't need caching. Developers aren't observing polling performance on their MacBooks. The cache being a no-op in dev is actually preferable — it means dev behavior matches "no cache" behavior, making issues easier to reproduce.

Detection is available if needed later: wp_using_ext_object_cache() returns whether an external cache is active. If a non-Garden deployment needs caching without memcached, we could add a transient-based fallback behind this check. But this adds a code path that needs separate testing for no current users.

Risk 3: Per-User Data Leaks and Permission Bypass

For WooPayments Tier 2, caching happens inside get_items(), which runs after the route's permission_callback. No additional capability gate is needed — unauthorized users are rejected before reaching the cache logic.

For orders Tier 1, rest_pre_dispatch bypasses permission_callback entirely. The cache handler MUST explicitly call current_user_can('edit_shop_orders') before returning cached data. Without this, any authenticated user (e.g., a subscriber) could hit /wc/v4/orders directly and receive cached order data. This is implemented in the Section 7 code.

Cache keys do not include user ID because the data is identical for all users with the required capability. If list payloads become user-shaped in the future, include user ID (or capability signature) in cache keys.

Risk 4: Cache Key Collisions

MD5 of JSON-encoded params provides sufficient uniqueness. Key length well within memcached's 250-byte limit (~80 chars total).

Risk 5: Cache Fails to Invalidate (Stale Data Served)

Scenario: Webhook is delayed, dropped, or ActionScheduler replay fails. The cache holds stale data and the TTL hasn't expired. Every request — including browser refresh — gets the cached response.

Why this is the most important risk: Unlike a cache miss (which just means one slow request), a stuck cache silently serves wrong data. The user has no indication anything is wrong and no way to force a refresh.

Three mitigations, layered:

  1. Conservative safety-net TTLs. Even without webhook invalidation, the cache expires:

    • Transactions: 5 min — frequent changes, short safety net
    • Disputes: 30 min — rare events, long safety net acceptable
    • Payouts: 30 min — very rare changes, long safety net acceptable
  2. _freshness=fresh page-load bypass (auto-heal). Every page navigation or browser refresh sends _freshness=fresh, which skips the cache and repopulates it with real data. This means:

    • Stale data is bounded to the current browsing session's polls. As soon as the user navigates away and back (or refreshes the page), the cache is auto-healed.
    • Worst case staleness = TTL for polls within a single session. For transactions, a stuck cache can serve stale data for at most 5 minutes of polling before the TTL expires. For disputes/payouts, up to 30 minutes — but only within continuous polling without any page navigation.
    • Typical real-world staleness is much shorter. Users regularly navigate between pages, and each navigation heals the cache.
  3. X-CIAB-Cache + X-CIAB-Cache-Reason response headers. Visible in Browser DevTools Network tab. When debugging "stale data" reports, check:

    • All responses showing HIT → cache is serving stale data → navigate away and back to heal, or investigate webhook delivery
    • Mix of HIT and MISS/BYPASS → cache is working, data is fresh
    • DEGRADED_BYPASS → upstream issue was surfaced but intentionally not cached

Net effect: The combination of TTLs + _freshness bypass makes "permanently stale" data impossible. The cache auto-heals on every page navigation, and TTLs provide a hard upper bound even without navigation.

Risk 6: Cache Stampede on MISS

Scenario: After a version bump or TTL expiry, many concurrent polls MISS and all issue upstream fetches simultaneously.

Mitigation: Add per-key short lock (wp_cache_add( lock_key, ... )) so one request repopulates cache. Other requests should briefly wait, use stale, or fall through only after lock timeout.

Risk 7: Caching Degraded Fallback Responses

Scenario: Upstream non-200 responses are currently normalized to empty 200 lists for resilience. If cached, a temporary upstream incident becomes a sustained false-empty state.

Mitigation: Return degraded fallback responses to the client but skip cache store for those responses.

Risk 8: Observability Blind Spots

Scenario: Cache logic appears correct in code review but behaves differently in production (low hit rate, silent staleness, lock contention), and we have no fast way to diagnose.

Mitigation (day-1 instrumentation contract):

  1. Standard response headers on cached endpoints
    • X-CIAB-Cache: HIT|MISS|BYPASS|DEGRADED_BYPASS
    • X-CIAB-Cache-Reason: fresh_param|no_entry|lock_contended|upstream_non_200|no_ext_cache
  2. Structured event hook for each request
    • do_action( 'ciab_polling_cache_event', $event )
    • Suggested fields: endpoint, cache_status, reason, freshness_param, ext_object_cache, lock_wait_ms, upstream_called, upstream_status, response_ms
  3. Log anomalies by default (not every request)
    • Always log: degraded bypass, lock timeout/contention spikes, upstream non-200, staleness incidents
    • Optional sampling for normal HIT/MISS traffic
    • Use WooCommerce logger (wc_get_logger()) with a dedicated source (e.g. ciab-polling-cache)
  4. Staleness incident detection
    • On _freshness=fresh, compare existing cached payload hash (if present) with fresh payload hash for the same normalized key
    • Emit staleness_incident=true when hashes differ

Risk 9: No Production Kill Switch

Scenario: Cache logic causes unexpected behavior in production (stale data, permission issues, performance regression). There is no way to disable it without deploying a code change.

Mitigation: All cache paths check for CIAB_DISABLE_POLLING_CACHE constant. Setting this in wp-config.php or via a deploy-time constant immediately disables all polling cache — both Tier 1 (orders, customers) and Tier 2 (WooPayments). The code falls through to the normal uncached path with zero side effects.

// In wp-config.php or environment config:
define( 'CIAB_DISABLE_POLLING_CACHE', true );

This is a day-1 requirement. The constant check is included in both the WooPayments base class get_items() and the orders rest_pre_dispatch handler (see Section 7).

Risk 10: Stale Cache Schema After Deploy

Scenario: A deploy changes the response format (adds/removes fields). Cached entries from the old format are served until TTL expires or a version bump occurs, causing frontend errors or missing data.

Mitigation: All cache keys include a static CIAB_CACHE_SCHEMA_VERSION integer. Bumping it during a deploy that changes response format immediately orphans all old entries (they become unreachable via the new key pattern). This is the same namespace-based invalidation pattern used for data version bumps, applied to schema changes.


11. Impact Estimates

These estimates assume healthy webhook delivery, effective invalidation signals, and stampede protection in place.

Cost Reduction Per Poll

Entity Current Cost With Cache HIT Savings
Orders Full WP_Query (~50-200ms) Memcached version check + response (~1ms) ~98-99%
Customers Analytics query + mapping (wc_customer_lookup/order stats + optional WC_Customer hydration, ~30-120ms) Memcached version check + response (~1ms) ~96-99%
Transactions Remote HTTPS to Transact Platform (~200-500ms) Memcached GET (~0.5ms) ~99%
Disputes Remote HTTPS to Transact Platform (~200-500ms) Memcached GET (~0.5ms) ~99%
Payouts Remote HTTPS to Transact Platform (~150-400ms) Memcached GET (~0.5ms) ~99%

Cache Hit Rate Estimates

Orders/Customers (version-based): 90-99%. A typical store receives orders at irregular intervals. Between orders, every poll is a cache hit. During a burst, cache invalidates per order but repopulates immediately.

Transactions/Disputes (webhook-invalidated): 95-99%. Between webhooks, every poll is a cache hit. A typical store processes payments sporadically — most 30-second intervals have no webhook.

Payouts (webhook-invalidated, 30min TTL): ~99%. Webhook-invalidated like transactions and disputes. Payout events are rare (daily/weekly), so between events the cache is almost always hit. The 30-minute safety-net TTL means at most 1 miss per 30 minutes even without webhooks. Page navigations (_freshness=fresh) also repopulate the cache.

Upstream API Call Reduction

For a single admin user with transactions page open, 1 hour:

Without cache With cache
Upstream API calls 120 (2/min × 60 min) ~2-10 (only after webhooks)
DB queries (orders) 120 ~1-5 (only after order changes)

12. Implementation Phases

Phase 1A: WooPayments Caching + Guardrails (Lowest Risk, Highest Cost Reduction)

Why first: WooPayments caching eliminates expensive upstream HTTPS round-trips to the Transact Platform. It uses direct method caching inside get_items() (runs after permission checks — no authorization bypass risk), making it the simplest and safest starting point.

Scope:

  1. CIAB_CACHE_SCHEMA_VERSION constant and CIAB_DISABLE_POLLING_CACHE feature flag.
  2. Cache logic in WooPayments_Next_List_REST_Controller::get_items() (version-prefixed keying, non-data param stripping).
  3. get_cache_ttl() overrides for transactions (5min), disputes (30min), deposits (30min).
  4. woocommerce_payments_after_webhook_delivery invalidation hook (including charge.dispute.updated).
  5. _freshness bypass support in cache logic.
  6. Mutation-level invalidation: Explicit version bumps for CIAB UI mutations (dispute evidence submission, etc.) — not deferred to a later phase, because submitting evidence and seeing stale data on the list is a bad UX.
  7. Guardrails: stampede lock + skip cache store for degraded fallback responses.
  8. Cache observability headers:
    • X-CIAB-Cache: HIT|MISS|BYPASS|DEGRADED_BYPASS
    • X-CIAB-Cache-Reason: fresh_param|no_entry|lock_contended|upstream_non_200|no_ext_cache|disabled

Exit criteria:

  • Cache hits observed on steady-state polls.
  • Webhook event triggers version bump and next poll MISS.
  • No sustained false-empty behavior during simulated upstream non-200 responses.
  • Lock contention remains low under concurrent polling tests.
  • Mutation (e.g., dispute response) bumps version and next poll returns updated data.
  • Feature flag (CIAB_DISABLE_POLLING_CACHE) cleanly disables all caching.

Phase 1B: Orders Caching (Higher Complexity — rest_pre_dispatch)

Why separate from 1A: Orders caching uses rest_pre_dispatch, which bypasses WordPress permission callbacks. This introduces a security-sensitive code path (explicit capability check required) and potential interaction with test infrastructure that already hooks rest_pre_dispatch for upstream mocking. Shipping WooPayments first and validating the pattern reduces risk.

Scope:

  1. Orders cache via rest_pre_dispatch short-circuit for GET /wc/v4/orders.
  2. Explicit current_user_can('edit_shop_orders') check before returning cached data — mandatory because rest_pre_dispatch bypasses route permission_callback.
  3. Orders cache store in rest_post_dispatch (after enrichment, priority 20).
  4. Orders enrichment guard for cache HIT responses (X-CIAB-Cache: HIT header check in batch_enrich_orders).
  5. Same cache key design (schema version, param stripping, ksort).

Exit criteria:

  • Phase 1A is validated in staging with no regressions.
  • Orders cache HIT rate is high under typical navigation/filtering.
  • PHPUnit tests cover filter-priority interaction with existing rest_pre_dispatch test mocks.
  • Capability check prevents cache access for unauthorized users.

Phase 1C: Staging Soak + Instrumentation Validation

Scope:

  1. PHPUnit coverage for HIT/MISS/BYPASS/lock behavior (both tiers).
  2. Tests for parameter normalization (ksort, _freshness stripping, non-data param stripping).
  3. Tests for rest_pre_dispatch capability gating (verify unauthorized users don't receive cached data).
  4. Emit structured cache event for each request via do_action( 'ciab_polling_cache_event', $event ).
  5. Staging dashboards/logs for: hit/miss rates, degraded-response bypass counts, lock contention, upstream-call reduction.
  6. Staleness incident detection on _freshness=fresh by comparing cached vs fresh payload hash for the normalized key.
  7. Anomaly logging policy using wc_get_logger() (degraded bypass, lock timeout, upstream non-200, staleness incidents).

Why here: Prevents expanding the pattern to customers before WooPayments+orders behavior is operationally validated.

Gate to proceed to Phase 2+:

  • Sustained high hit rate on active polling views (target: >90% for WooPayments list pages).
  • Sustained high hit rate on orders list under typical navigation/filtering patterns.
  • Degraded bypass events are rare and tied to real upstream incidents.
  • No unexplained staleness incidents in staging soak.

Phase 2: Customers Cache (With Correct Invalidation Signal)

Scope:

  1. Cache in Customers\Controller::get_items().
  2. Composite version strategy (users + posts/orders + excluded-statuses hash) as baseline.
  3. Optional refinement: custom ciab_customers_version hooks if baseline false invalidation is high.
  4. Preserve required headers on HIT (Allow, pagination).

Gate: Do not ship this phase with users-only invalidation.

Phase 3: Frontend _freshness Integration

Add _freshness: 'fresh' in list route loaders (orders, customers, transactions, disputes, payouts). Keep polling requests unchanged.

Phase 4: Tuning

  • Tune TTLs with real hit/miss and staleness data.
  • Keep debug visibility (X-CIAB-Cache + logs) until behavior is stable.

13. Decision Critic Review

This section documents the results of a structured stress-test of the caching strategy, following the Chain-of-Verification methodology.

Claims Verified Against Codebase

Claim Status Evidence
woocommerce_payments_after_webhook_delivery fires for payout events Verified Hook fires unconditionally AFTER the switch statement in WC_Payments_Webhook_Processing_Service. No matching case for payouts = switch does nothing, hook still fires with full $event_type and $event_body.
HPOS orders update wp_cache_get_last_changed('posts') Verified HPOS OrdersTableDataStore inherits clear_caches() from the abstract CPT datastore, which calls clean_post_cache($order_id)wp_cache_set_last_changed('posts'). The chain works even though HPOS stores data in wc_orders, not wp_posts.
rest_post_dispatch fires on rest_pre_dispatch short-circuited responses Verified WordPress calls rest_post_dispatch in serve_request() AFTER dispatch() returns, regardless of how the response was produced. This is correct — and reveals the double-enrichment issue (addressed in Section 7).
WooCommerce RestApiCache does not cover orders/customers Verified Only covers Products, Taxes, Data endpoints. Experimental, disabled by default.
charge.dispute.updated is NOT forwarded by Transact Platform Corrected Originally claimed this event was not forwarded. CIAB's own WooPayments_Next_Dispute_Webhook_Tracker (woocommerce-payments-next/lib/order-integration/) explicitly lists charge.dispute.updated in its DISPUTE_EVENTS constant and processes it. Added to the invalidation map (Section 4).
rest_pre_dispatch bypasses permission_callback Verified WordPress runs permission_callback in dispatch() AFTER the rest_pre_dispatch filter. If rest_pre_dispatch returns a response, route matching, permission checks, and argument validation are all skipped. Explicit capability check added to orders cache handler (Section 7).

Bugs Found and Addressed in the Plan

# Bug Fix Applied
1 Cache key non-determinism. wp_json_encode($request->get_params()) without key sorting produces different hashes for logically identical requests with different parameter ordering. Addressed in plan: ksort($params) before hashing in all cache key generation (Sections 4, 5, 7, 9).
2 _freshness cache key mismatch. Fresh page-load requests would cache under a key including _freshness, but polls (without _freshness) look up a different key. Addressed in plan: unset($params['_freshness']) before key generation (Sections 4, 5, 7, 9).
3 Double enrichment on orders cache hit. rest_post_dispatch fires on short-circuited responses, so batch_enrich_orders can re-run enrichment queries. Addressed in plan: guard on cache HIT in batch_enrich_orders (Section 7, Tier 1 Orders).
4 Header preservation under-specified. X-WP-Total and X-WP-TotalPages were noted but not fully specified. Addressed in plan: store/restore required response headers, including Allow for customers (Sections 7, 9).
5 Potential cache stampede. Concurrent MISS traffic can fan out upstream calls after invalidation. Addressed in plan: per-key short lock around MISS handling (Sections 4, 7, 10).
6 Degraded-response caching risk. Upstream non-200 fallback empty responses could be cached as real empty state. Addressed in plan: return degraded fallback responses but skip cache store (Sections 4, 7, 10).
7 Customers invalidation signal too narrow. Users-only invalidation misses order-driven analytics changes. Addressed in plan: composite users+posts/orders versioning or custom customers version hooks (Sections 3, 7).
8 rest_pre_dispatch permission bypass. Orders cache returns data without verifying user capabilities, since rest_pre_dispatch fires before permission_callback. Any authenticated user could access cached order data. Addressed in plan: explicit current_user_can('edit_shop_orders') check in rest_pre_dispatch handler (Section 7, Tier 1 Orders).
9 Missing charge.dispute.updated in invalidation map. Dispute status transitions via this event would not invalidate the cache, serving stale dispute data. Addressed in plan: added to both event table (Section 3) and invalidation map (Section 4).
10 No production kill switch. No mechanism to disable caching without a code deploy if unexpected behavior occurs. Addressed in plan: CIAB_DISABLE_POLLING_CACHE constant checked in all cache paths (Sections 7, 10).
11 Stale cache schema after deploy. Response format changes could serve old-format cached data until TTL expires. Addressed in plan: CIAB_CACHE_SCHEMA_VERSION in all cache keys (Sections 7, 9, 10).
12 Mutation invalidation deferred too late. Dispute evidence submission (and other CIAB UI mutations) only invalidated badge cache, not list cache, causing stale list data after user actions. Addressed in plan: mutation-level invalidation moved to Phase 1A (Section 12).

Design Recommendations

# Recommendation Status
1 wp_cache_get_last_changed('posts') is broad — blog posts, products, and pages also invalidate the orders cache. Add cache hit/miss logging from day 1 to measure false-invalidation rate. Noted in Section 7 (Tier 1 Orders). Start broad, measure, refine if needed.
2 Caching behavior must be testable without memcached. Add PHPUnit tests that mock wp_cache_get/wp_cache_set and cover lock/bypass logic. Promoted to Phase 1C as a required gate before expanding to customers.
3 Consider frontend exponential backoff as a complementary measure (double poll interval after N identical responses). Reduces poll volume ~50-70% with zero backend work, independent of caching. Out of scope for this analysis — separate frontend optimization.
4 Existing PHPUnit fixtures already intercept rest_pre_dispatch for upstream mocks. New orders cache interception must be tested carefully for filter-priority interaction to avoid brittle tests. Added to Phase 1B/1C scope — orders caching is now a separate phase to reduce risk.
5 Instrumentation must be explicit from day 1: cache status/reason headers, structured cache-event action, anomaly-first logging, and staleness incident tracking. Added in Risk 8 and Phase 1A/1C scope with concrete fields and gates.
6 Customer cache invalidation should use a composite baseline (users + posts/orders + excluded-statuses hash) before considering custom hook-based precision. Added in Tier 1 Customers and Phase 2 scope.
7 Ship WooPayments caching before orders caching. The rest_pre_dispatch approach for orders is more complex (permission bypass, test fixture interaction) than in-method caching for WooPayments. Validate the simpler pattern first. Adopted: Phase 1A (WooPayments) is now separate from Phase 1B (Orders).
8 Strip non-data params from cache keys. After thorough investigation of all WordPress REST API, @wordpress/api-fetch, and @wordpress/core-data params: strip _freshness, _locale, _envelope, _pretty, _jsonp, rest_route. Keep context, _fields, _embed (they affect response content). See Section 9 for full classification with rationale. Adopted in Section 9 cache key design with comprehensive parameter classification table.

Challenges Considered and Dispositioned

Challenge Disposition
"The problem is polling, not caching" — invest in SSE or exponential backoff instead. Caching and poll-frequency optimization are orthogonal. Caching eliminates backend cost per poll. Backoff reduces poll count. Both are valid, and caching has higher immediate impact for WooPayments (eliminates remote HTTPS round-trips). Backoff is a complementary frontend optimization that doesn't require backend changes.
30-minute dispute TTL hides a UX problem — merchant receives dispute email, opens CIAB, cache shows stale list. Mitigated by _freshness=fresh — opening the disputes page always fetches real data. The 30-minute TTL only affects in-session polling. If the user is already on the disputes page and a new dispute arrives, the webhook invalidates the cache within seconds and the next 30s poll picks it up.
No-op without memcached means untestable in dev Correct, but: (1) unit tests can mock wp_cache_*, (2) dev doesn't need caching performance, (3) staging/Garden has memcached for integration testing. The tradeoff is acceptable.
rest_pre_dispatch interacts with test infrastructure and other plugins Acknowledged. Existing PHPUnit test fixtures use rest_pre_dispatch for upstream mocking. Priority ordering must be tested. Other plugins hooking rest_pre_dispatch could interfere but this is standard WordPress filter behavior — CIAB's handler only intercepts a specific route and returns $result (pass-through) for everything else. Mitigated by separating orders caching into Phase 1B.

Follow-up Work

Added 2026-03-02 after full-branch code review.

useWindowFocus deduplication

The useWindowFocus helper added inline in use-auto-refresh.ts (lines 24–41) duplicates an identical hook in google-next/packages/components/src/hooks/use-window-focus-callback-interval-effect.ts (lines 11–31). Both listen to window focus/blur events and expose a boolean via useState/useEffect — they differ only in variable names.

admin-toolkit is the correct shared home: it's a core infrastructure package all plugins can depend on. google-next currently maintains its own copy because the dependency didn't exist before this branch.

Action items (not blocking the cache PR):

  • Export useWindowFocus from @automattic/admin-toolkit as a public API
  • Update google-next to import useWindowFocus from @automattic/admin-toolkit and remove its local copy
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment