Really Simple Embettered Page Cache
Drupal's page cache is great while it's populated, but once a page is invalidated (for example when its content changes, or after a cache clear), the next request which required the page to be rebuilt is blocked until the page has been built.
On a busy site, or a site which is being crawled aggressively, this can cause a traffic stampede to build multiple pages, which in turn can overwhelm most web servers.
This module will continue to serve cached pages after they become "stale" (that is, after the content they show has changed, or after the cache has been cleared), but in the background will rebuild the page, without blocking requests.
Background page refreshes happen immediately at the end of the current request, up to a maximum of two (configurable in admin > config > development > performance) concurrent build sessions; if more than this number of stale pages need to be built at the same time, further requests are deferred to a queue which is processed by cron.
The problem
Drupal's internal page cache stores each anonymous page as permanent, together with its cache tags. When a tag the page depends on is invalidated, or the cache is cleared, the next visitor has to wait while Drupal builds the whole page again.
Invalidation is frequent on any site with listings. Saving a single node invalidates node_list, which affects every page that lists content, and every deployment that runs drush cr empties the page cache entirely.
What this module does
When a visitor asks for a page that is:
- cached and valid: they get the cached page (
X-Drupal-Cache: HIT), exactly as in core. - cached but invalidated: they get the stale copy immediately (
X-Drupal-Cache: STALE). The page is then rebuilt after the response has been sent, or queued for cron. - not cached at all: the page is built on the spot (
MISS), exactly as in core.
At most two pages are rebuilt at once, whether on request or by cron. A stale page that finds both slots busy is added to a queue and rebuilt by cron. A queued page that is valid again by the time cron reaches it, or has been purged since, is skipped.
A full cache rebuild (drush cr) no longer deletes cached pages. It marks them stale, so the first visit to each page after a deployment is served instantly and the page is rebuilt behind it.
Rarely visited pages benefit most. A page can be served stale for up to a year by default, so it stays fast even if nobody has requested it for a long time.
Cache-Control on stale responses
A stale response carries a short Cache-Control: public, max-age=N. N is the smallest of the stale max-age option (60 seconds by default), the max-age the page was stored with, and the site's page cache maximum age (the "Browser and proxy cache maximum age" setting on the Performance page). That setting does not decide when a page becomes stale, only how long a browser or CDN may keep it.
Without this, a CDN or reverse proxy in front of the site would keep the stale copy for the full page maximum age. Fresh and rebuilt pages keep the header they always had. A site whose page maximum age is 0, and any page sent as private or no-cache, is left exactly as it was.
Requirements
- Drupal 10 or 11, with the core Internal Page Cache (
page_cache) module enabled. - PHP 8.1 or later.
- No contributed modules are required. It works with whatever backend the page cache uses (database, Redis and so on) and needs nothing added to
settings.php.
Settings
All options are optional. They are on the Performance page (admin > config > development > performance), in a "Stale-while-revalidate page cache" section. Any option can also be fixed in settings.php with the key shown below. A value in settings.php always wins, and the Performance page then shows that option read-only, with a note saying why.
$settings['embettered_page_cache_enabled'](defaultTRUE): a kill switch.FALSEbehaves exactly like core, includingdrush crdeleting pages.$settings['embettered_page_cache_stale_max_age'](default60): seconds a stale response may be cached downstream.$settings['embettered_page_cache_max_stale_age'](default31536000, one year): the oldest a page may be and still be served stale. It is also the lifetime given to every stored page, so invalidated pages are eventually reclaimed. Older pages are built on the spot.$settings['embettered_page_cache_rebuild_slots'](default2): how many pages are rebuilt at once, on request or by cron.$settings['embettered_page_cache_lock_timeout'](default120): crash-safety timeout on the rebuild locks. Keep it at or above your PHP-FPMrequest_terminate_timeout.$settings['embettered_page_cache_queue_dedupe_window'](default900): seconds before a queued page may be queued again. About one cron interval.$settings['embettered_page_cache_failure_backoff'](default60): seconds to leave a page alone after a rebuild could not be stored.
Things to know
drush crno longer clears cached pages. To delete them for real, for example to see a configuration change straight away, rundrush php:eval "\Drupal::cache('page')->purge();".- Installing the module empties the page cache once. Pages stored before it existed lack the tag that
drush crnow invalidates, so they would otherwise survive a deployment with the old release's markup. - A page that has never been cached is still built on the spot, because there is nothing to serve.
- Cached 403 and 404 responses are not served stale. A page that was missing is far more likely to exist now than to still be missing, so it is rebuilt straight away.
- A page that will not cache is not retried on every visit. If a rebuild returns something core will not store, such as a server error or an exception, the page is left alone for a short back-off period. If the page now comes back gone or forbidden, its stale copy is deleted. A server error keeps the stale copy, because stale is better than an error.
- A rebuild sees its own request. Code that reads
$_SERVER,$_GETor$_COOKIEdirectly gets values describing the page being rebuilt, with the visitor's cookies and headers removed. Cron rebuilds run as the main request, and the active theme, static caches, entity memory cache and page cache kill switch are reset between pages. - Old cache entries can be unreadable after a release. If a cached page no longer unserialises, it is treated as not cached and overwritten.
Known limitations
- Services first used during a rebuild that follows a stale response are not destructed, because core has already finished its own shutdown work by then. They are persisted by the next full request.
- Code that calls
exit()during a request kills the rebuild, and any locks it held lapse after the lock timeout. This only matters for a queued URL that such code would redirect, which a page that was cached in the first place normally is not. - Rebuilt pages use the triggering visitor's timezone if a module changes it per visitor (for example by GeoIP), the same as core's first-visitor-builds-the-page behaviour. Cron rebuilds use the site default.
- The persistent lock backends share one owner ID, so a rebuild that outlives the lock timeout can release another process's lock.
Monitoring
X-Drupal-Cache: STALEon a response means a stale copy was served.- The
really_simple_embettered_page_cachelog channel records rebuilds that could not be stored (warning), exceptions (error) and queueing (debug). drush queue:listshows pages waiting for cron.
How it works
- The page cache middleware and the
cache.pagebin are altered in a service provider, so nothing needs configuring and the module works on any cache backend. - Rebuilds run in a PHP shutdown function, after the response has been sent to the visitor. Drupal's own
terminate()is not used, because it only runs after the kernel has been prepared, which a page cache answer never reaches. - Every stored page carries one extra cache tag, and deleting the whole page cache invalidates that tag instead of deleting rows. The backend's own
invalidateAll()is deprecated from Drupal 11.2, so it is not used. - Concurrency is limited with Drupal's Lock API: a counting semaphore built from named locks, a per-page rebuild lock, and a per-page queued lock that is deliberately never released, so its timeout is what allows the page to be queued again.
Tests
The module ships with unit and kernel tests. The kernel tests need SIMPLETEST_DB and check the service alterations against a compiled container and a real database cache bin, including that a cache flush leaves pages stale rather than deleting them.
Depends on
Dependencies of the latest stable release
- page_cache Drupal core
Required by
Tracked projects that depend on this one
No tracked projects depend on this one yet.
Activity
Releases
| Version | Type | Core | Notes | Release date | |
|---|---|---|---|---|---|
| 1.0.0 | Stable | 10–11 | Initial release of RSEPC. | Oct 1, 2026 |