Automattic / Automattic/newspack-workspace

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

Open
#778 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
PHP
Stars
22
Forks
18
Avg merge
2d 23h
Merged PRs (30d)
131

Description

**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.

Contributor guide

Open the contributing guide

Research direction

Start in includes/class-newspack-blocks-caching.php and trace get_cached_block_data() for homepage-articles and carousel entries. Reproduce the 120-second expiry with several blocks, then review the proposed soft/hard TTL, stale response, lock, and shutdown regeneration behavior. Done means stale requests remain fast while regeneration refreshes the same cache key without duplicate work.

Written by the indexing model from the issue text.

Assessment

Tech stack
php, wordpress
Domain
backend, performance
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.