Drupal is a registered trademark of Dries Buytaert

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:

  1. A component (e.g., a countdown timer or a "Next Upcoming Event" block) tells Drupal: "This data is only valid for 60 seconds."
  2. Drupal core calculates the page's max-age as 60 seconds.
  3. Internal Page Cache ignores that calculation and stores the page for 1 hour (or permanently).
  4. 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:

  1. It verifies the response is a CacheableResponseInterface.
  2. It checks if the current user is anonymous (since the Internal Page Cache only serves anonymous users).
  3. It extracts the final, bubbled-up max-age value from the response's cache metadata.
  4. If the max-age is 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 Expires header: This is the specific header that Drupal's FinishResponseSubscriber and Internal Page Cache use to determine the storage lifetime.

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:

  1. Log out or open a private/incognito window.
  2. Inspect the network headers of a page containing a component with a
    restricted max-age (like the "Next Upcoming Event" block).
  3. Observe the Cache-Control and Expires headers.
  4. The max-age should 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-age of 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 the Expires header.

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 to no-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-age that overrides the bubbled metadata in specific scenarios.
    • Handling max-age: 0: It explicitly overrides max-age: 0 with a configured global value (e.g., 1 hour).
      • From their code: if ($max_age === 0) { $max_age = $config->get('max_age'); }
    • 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:

Activity

Total releases
1
First release
Jan 2026
Latest release
1 month ago
Release cadence
Stability
0% stable

Releases

Version Type Release date
1.0.x-dev Dev Jan 26, 2026