Skip to content

Homepage-Articles/Carousel block cache: hard TTL causes full synchronous re-render on expiry (proposal: stale-while-revalidate) #778

Description

@jhalitschke

Plugin/theme affected
newspack-blocks (includes/class-newspack-blocks-caching.php)

Describe the bug
Newspack_Blocks_Caching caches newspack-blocks/homepage-articles and newspack-blocks/carousel block output in the object cache for NEWSPACK_BLOCKS_CACHE_BLOCKS_TIME (default 120s). On expiry, get_cached_block_data() deletes the entry and returns false, forcing every subsequent request to re-render the block synchronously until a new entry is written.

On pages that use many of these blocks (a common homepage pattern — one per content section), every single cache expiry costs a full synchronous re-render of all of them at once, not just one block.

Measured impact on a production news site with ~35,000 published posts, front page built from ~30 homepage-articles/carousel blocks (one per content section), tested across three environment tiers of the same install:

Environment Cold render (empty object cache) Re-render after natural 120s TTL expiry
A — local Docker dev 22-24s same cost, every cycle
B — hosted dev sandbox 127-130s same cost, every cycle
C — independent hosted sandbox, unpatched control 66.5s 56.7s (confirms it recurs every cycle, not a one-off)

The absolute numbers vary with environment resourcing (A/B/C are the same content, not different-sized sites), but the pattern — full re-render, every ~120s, indefinitely — is constant. We'd expect this to scale with content volume: each block's underlying query does meta/term joins and thumbnail/curation lookups whose cost grows with table size, so an install with a few thousand posts likely sees a much smaller (maybe not even noticeable) version of this; we haven't measured that end of the spectrum directly.

This isn't a rare edge case — on a site that publishes frequently, the edge/page cache (e.g. a CDN's own stale-while-revalidate layer) also invalidates often, and its revalidation request goes through the same origin path. If the object-cache entry has also expired at that moment (likely, given the short TTL), that revalidation request pays the full cost — sometimes a real visitor's or a search-engine crawler's request, not just an internal warmer. We observed multi-second to ~95s page-load spikes hitting real browser user agents and Googlebot, spread unpredictably across a week, on several sites sharing this pattern.

To Reproduce

  1. On a site with several homepage-articles/carousel blocks on one page, flush the object cache (or wait past NEWSPACK_BLOCKS_CACHE_BLOCKS_TIME).
  2. Load the page as a logged-out visitor — full synchronous render, cost scales with block count and content volume.
  3. Load it again immediately — fast (served from cache).
  4. Wait past NEWSPACK_BLOCKS_CACHE_BLOCKS_TIME (120s default) and load again — pays the full cost again, every time, indefinitely.

Expected behavior
Proposed fix: soft/hard TTL split with background regeneration.

  • Soft TTL (current NEWSPACK_BLOCKS_CACHE_BLOCKS_TIME, unchanged default): past this age, an entry is stale but still returned/served immediately — never dropped.
  • Hard TTL (new, e.g. 24h): only past this age is an entry discarded and rendered synchronously (should practically never trigger in normal operation).
  • On a stale hit: serve the stale content immediately, and queue a background regeneration for that block, guarded by a short-lived wp_cache_add() lock (so concurrent requests during the same stale window don't all regenerate independently).
  • Regeneration runs after the response has already been sent to the client (fastcgi_finish_request() where available, on shutdown), calling render_block() on the original parsed block data and re-storing the result under the same cache key.

Net effect: no visitor (or edge-cache revalidation request) ever waits for a full re-render again after the very first cache population — the cost is paid once, in the background, regardless of how many blocks are on the page or how often content changes.

We've implemented and verified this approach (details below) and are happy to open a PR rebased against current main if this direction is wanted — wanted to check first since it changes caching semantics (stale-serving instead of hard-drop) rather than just tuning a constant.

Verification
Implemented and tested against a local dev environment and two independent WordPress VIP environments (one patched, one left as an unpatched control):

  • Patched environment: cold render once (127-130s), then 0.6-1.2s on every subsequent stale cycle (multiple cycles tested, and across desktop/mobile/Googlebot user agents — the shared object-cache key doesn't vary by user agent, so any one of them refreshes it for all).
  • Unpatched control environment: stays at 56-67s on every cycle, confirming the problem is real and the improvement isn't environmental noise.

Environment

  • WordPress version: 6.9.5
  • PHP version: 8.3.32
  • Browser (if applicable): not browser-specific — reproduced via curl with desktop, mobile, and Googlebot user agents; all affected equally since the cache key doesn't vary by user agent.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions