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
- On a site with several
homepage-articles/carousel blocks on one page, flush the object cache (or wait past NEWSPACK_BLOCKS_CACHE_BLOCKS_TIME).
- Load the page as a logged-out visitor — full synchronous render, cost scales with block count and content volume.
- Load it again immediately — fast (served from cache).
- 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.
Plugin/theme affected
newspack-blocks (
includes/class-newspack-blocks-caching.php)Describe the bug
Newspack_Blocks_Cachingcachesnewspack-blocks/homepage-articlesandnewspack-blocks/carouselblock output in the object cache forNEWSPACK_BLOCKS_CACHE_BLOCKS_TIME(default 120s). On expiry,get_cached_block_data()deletes the entry and returnsfalse, 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/carouselblocks (one per content section), tested across three environment tiers of the same install: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
homepage-articles/carouselblocks on one page, flush the object cache (or wait pastNEWSPACK_BLOCKS_CACHE_BLOCKS_TIME).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.
NEWSPACK_BLOCKS_CACHE_BLOCKS_TIME, unchanged default): past this age, an entry is stale but still returned/served immediately — never dropped.wp_cache_add()lock (so concurrent requests during the same stale window don't all regenerate independently).fastcgi_finish_request()where available, onshutdown), callingrender_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
mainif 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):
Environment