page_cache_max_age_bubbling
The Page Cache Max-Age Bubbling module addresses a long-standing limitation in Drupal core's caching architecture where the Internal Page Cache (part of the mandatory page_cache module) does not always correctly respect the max-age cache metadata bubbled up from individual components (blocks, nodes, etc.) on a page.
This module ensures that the lowest bubbled-up max-age is explicitly applied to the Cache-Control header's max-age directive and the Expires header, forcing both internal and external caches to honor the intended freshness of the content.
The Problem
Drupal's Cache API is designed to "bubble up" cacheability metadata. If a block on a page has a max-age of 300 seconds (5 minutes), the entire page should ideally be cached for no longer than 300 seconds.
However, while Drupal core correctly bubbles this metadata to the response object, the Internal Page Cache often ignores it. Instead, the Internal Page Cache typically relies on the site-wide "Expiration of cached pages" setting (found at /admin/config/development/performance), which can be set to "None" (permanent) or a static value like 1 hour.
This results in a critical mismatch:
- A component (e.g., a countdown timer or a "Next Upcoming Event" block) tells Drupal: "This data is only valid for 60 seconds."
- Drupal core calculates the page's max-age as 60 seconds.
- Internal Page Cache ignores that calculation and stores the page for 1 hour (or permanently).
- Anonymous users see stale data because the Page Cache serves its stored version without re-evaluating the component's logic.
The Solution
This module implements an Event Subscriber that listens to the kernel.response event with a priority that allows it to act after the cache metadata has been fully bubbled up but before the response is stored by the Page Cache.
How it works:
- It verifies the response is a
CacheableResponseInterface. - It checks if the current user is anonymous (since the Internal Page Cache only serves anonymous users).
- It extracts the final, bubbled-up
max-agevalue from the response's cache metadata. - If the
max-ageis not permanent (-1), it performs two actions:- Forward-populates
Cache-Control: max-age: This ensures that external
caches like CDNs (Cloudflare, Akamai) or reverse proxies (Varnish) respect the specific component's expiration. - Sets the
Expiresheader: This is the specific header that Drupal'sFinishResponseSubscriberandInternal Page Cacheuse to determine the storage lifetime.
- Forward-populates
REQUIREMENTS
- Drupal 10.x or 11.x
- The core Internal Page Cache module (usually enabled by default).
INSTALLATION
Install as you would normally install a contributed Drupal module. Visit: https://www.drupal.org/node/1897420 for further information.
Extensibility
This module is designed to be extensible, allowing other modules to modify the calculated max-age or cancel the bubbling process entirely before headers are applied to the response.
Custom Event
The module dispatches a PageCacheMaxAgeBubblingEvents::PRE_APPLY event. You can subscribe to this event to implement custom business logic.
1. Subscribe to the Event
In your module's src/EventSubscriber/MyCustomSubscriber.php:
use Drupal\page_cache_max_age_bubbling\Event\PageCacheMaxAgeBubblingEvent; use Drupal\page_cache_max_age_bubbling\Event\PageCacheMaxAgeBubblingEvents; use Symfony\Component\EventDispatcher\EventSubscriberInterface; class MyCustomSubscriber implements EventSubscriberInterface { public static function getSubscribedEvents(): array { return [ PageCacheMaxAgeBubblingEvents::PRE_APPLY => 'onPreApply', ]; } public function onPreApply(PageCacheMaxAgeBubblingEvent $event): void { // Your custom logic here. } }
2. Modifying the Max-Age
You can override the bubbled-up max-age value before it is written to the headers:
public function onPreApply(PageCacheMaxAgeBubblingEvent $event): void { // Force a maximum of 60 seconds for a specific route. if (\Drupal::routeMatch()->getRouteName() === 'my_module.special_route') { $event->setMaxAge(60); } }
3. Stopping the Bubbling Process
If you want to prevent this module from modifying the response headers entirely for a specific request, you can stop the bubbling process:
public function onPreApply(PageCacheMaxAgeBubblingEvent $event): void { // Disable the fix for certain administrative or edge-case pages. if ($some_complex_condition) { $event->stopBubbling(); } }
Testing and Verification
To verify that the module is working:
- Log out or open a private/incognito window.
- Inspect the network headers of a page containing a component with a
restrictedmax-age(like the "Next Upcoming Event" block). - Observe the
Cache-ControlandExpiresheaders. - The
max-ageshould match the lowest value requested by any block on the page, rather than the site-wide default.
Technical Details & Limitations
Permanent Max-Age (-1)
This module acts as a pass-through for pages where the bubbled max-age is Cache::PERMANENT (-1). If no components on the page specify a numeric max-age, the module will not modify the headers. In this case, Drupal Core's default behavior applies, using the global "Expiration of cached pages" setting from system.performance.
Interaction with Cache Tags
This module complements Drupal's Cache Tag system; it does not replace it. Explicit invalidation still takes precedence.
- Scenario: A page has a
max-ageof 1 hour (3600s) because of a block, but it also displays a Node with a specific Cache Tag. - Result: The page is cached with an expiry time of 1 hour.
- Invalidation: If the Node is updated 5 minutes later, the Cache Tag is
invalidated, and the page is immediately cleared from the Internal Page
Cache, regardless of the remaining 55 minutes on theExpiresheader.
Server Time Dependency
Because this module sets the HTTP Expires header (which uses an absolute timestamp, e.g., Wed, 21 Oct 2015 07:28:00 GMT), it introduces a dependency on accurate server time. Ensure your server's clock is synchronized (e.g., using NTP). Significant clock skew could cause pages to be treated as immediately stale or cached for longer than intended by downstream clients.
Similar Modules
Cache Control Override
While both modules interact with the Cache-Control header, they serve fundamentally different purposes and handle "bubbled" metadata in opposing ways.
- Page Cache Max-Age Bubbling (This Module):
- Goal: Strict adherence to granular cache metadata.
- Behavior: If a block on the page says "I expire in 300 seconds", this module ensures the entire page expires in 300 seconds.
- Handling
max-age: 0: It respects it. If a component is uncacheable (max-age: 0), this module will set the page headers tono-cache, effectively disabling the Internal Page Cache for that response.- You could override this by implementing an event subscriber for the PageCacheMaxAgeBubbling event and checking for Max Age of zero and then stopping propagation of the bubbling.
- Maybe one day this module will have configuration for ignoring max-age of 0.
- Use Case: You have time-sensitive content (countdowns, "open now" status, upcoming events) and you want the Page Cache to respect their specific lifetimes.
- Cache Control Override:
- Goal: Enforcing global cache policies / Overriding "mistakes".
- Behavior: It allows you to set a global
max-agethat overrides the bubbled metadata in specific scenarios. - Handling
max-age: 0: It explicitly overridesmax-age: 0with a configured global value (e.g., 1 hour).- From their code:
if ($max_age === 0) { $max_age = $config->get('max_age'); }
- From their code:
- Use Case: You have a site where some poorly written modules set
max-age: 0(preventing caching entirely), and you want to force the site to cache anyway.
Key Difference: This module fixes the bubbling to be respected. Cache Control Override allows you to ignore the bubbling.
Disclaimers & Known Issues
Fighting the Internal Page Cache
Drupal's Internal Page Cache (page_cache module) is architected to cache pages "forever" until a Cache Tag is invalidated. It does not natively support time-based expiration (max-age) for storage; it only supports it for the browser/proxy headers.
As noted by Drupal core developer Berdir in this issue:
Core specifically only sets the max age setting on the response and the internal page cache is forever. ... [It] is common to set cache.page.max_age to a low value, because you have no means to invalidate it.
By using this module to set the Expires header, we are effectively "tricking" the Internal Page Cache into respecting a time-based expiration. While this works for the specific use cases described (e.g., the Lullabot article), it is technically working against the primary design pattern of the Internal Page Cache, which relies on explicit tag invalidation.
Performance Impact
- Shorter Cache Lifetimes: If you have a block with a 60-second max-age on your homepage, your homepage will now be regenerated by Drupal (a full bootstrap) every 60 seconds for anonymous users. This is significantly more resource-intensive than the default "cache forever" behavior.
- Cache Thrashing: Highly variable max-ages can lead to frequent cache rebuilds.
Recommendation
Only use this module if you specifically have time-dependent content (like "Next Service" blocks) that cannot be effectively managed via Cache Tags and requires an expiration strategy.
Support development on this module
A lot of hard work and dedication has gone into developing this module. If you find it helpful and would like to support ongoing development, consider buying me a coffee! Your support helps ensure that I can continue enhancing and maintaining this module for everyone. Thank you for your generosity!
References
This module is based on the research and implementation details found in:
- Lullabot Article: Common Max-Age Pitfalls with Drupal Cache
- Drupal.org Issue: Internal Page Cache ignores max-age bubbled metadata (#2352009)