---
title: "Reduce Content Entity Cache Tags"
date: "2025-06-23T17:43:10+00:00"
summary:
image:
type: "article"
url: "/acquia-cloud-platform/help/88751-reduce-content-entity-cache-tags"
id: "3258cfe7-9e52-4ae9-9a8f-9f3b8105d978"
---

Table of contents will be added

Drupal's implementation of cache tags is a powerful tool for cache invalidation; however, it can result in a large number of cache tags being rendered in the response header. Acquia's load balancers have a soft limit of 23kb for headers. While the [Acquia Purge](https://www.drupal.org/project/acquia_purge) module does hash cache tags to reduce the header size, it's still possible to exceed this limit. See [Varnish header limits on Acquia Cloud](https://acquia.my.site.com/s/article/360005396494-Varnish-header-limits-on-Acquia-Cloud) for more details.

One common cause is content entity cache tags.

Scenario: Taxonomy Term Cache Tags
----------------------------------

While a number of scenarios can result in too many cache tags, this post will examine a scenario where taxonomy terms are the culprit. For example, a site has an _Article_ content type with a _Tags_ taxonomy term entity reference field. Collectively, the site's _Article_ nodes reference over 1,000 taxonomy terms.

A view returning _Article_ nodes with the content type's _Tags_ field will generate a large cache tag header. Likewise, calling the JSON:API endpoint for the _Article_ content type with an `include` parameter for the _Tags_ field will generate a large cache tag header.

One solution is to consolidate individual `taxonomy_term` cache tags into `taxonomy_term_list` cache tags with an [event subscriber](https://www.drupal.org/docs/develop/creating-modules/subscribe-to-and-dispatch-events).

Step 1: Register the Event Subscriber
-------------------------------------

The first step is to register an event subscriber.

term\_cache\_tag\_consolidator.services.yml:

    services:
     term_cache_tag_consolidator.response_subscriber:
     class: Drupal\term_cache_tag_consolidator\EventSubscriber\ResponseSubscriber
     arguments: ['@database', '@current_route_match']
     tags:
     - { name: event_subscriber }
    

src/EventSubscriber/ResponseSubscriber:

    <?php 
    
    namespace Drupal\term_cache_tag_consolidator\EventSubscriber;
    
    use Drupal\Core\Database\Connection;
    use Drupal\Core\Routing\CurrentRouteMatch;
    use Symfony\Component\EventDispatcher\EventSubscriberInterface;
    use Symfony\Component\HttpKernel\Event\ResponseEvent;
    use Symfony\Component\HttpKernel\KernelEvents;
    
    /**
     * Replace individual taxonomy term cache tags with vocabulary tags.
     */
    class ResponseSubscriber implements EventSubscriberInterface {
    
     /**
     * Construct a ResponseSubscriber object.
     */
     public function __construct(
     private readonly Connection $database,
     private readonly CurrentRouteMatch $currenntRouteMatch,
     ) {}
    
     /**
     * {@inheritdoc}
     */
     public static function getSubscribedEvents(): array {
     // Execute just prior to
     // Drupal\Core\EventSubscriber\FinishResponseSubscriber.
     $events[KernelEvents::RESPONSE][] = ['onRespond', 17];
     return $events;
     }
    
     /**
     * Modify cache tags on designated cacheable responses.
     *
     * @param \Symfony\Component\HttpKernel\Event\ResponseEvent $event
     * The event to process.
     */
     public function onRespond(ResponseEvent $event) {
     if (!$event->isMainRequest()) {
     return;
     }
    
     }

Step 2: Replace Individual Term Cache Tags
------------------------------------------

Next, get the cache tags from the `Response` object:

    $response = $event->getResponse();
    
    // Only modify cache tags when this is a cacheable response.
    if ($response instanceof CacheableResponseInterface) {
     $tags = $response->getCacheableMetadata()->getCacheTags();
    }

Then, collect the term IDs and remove the individual `taxonomy_term` tags:

    // Loop through cache tags, remove taxonomy terms, and build a list of
    // term IDs from removed cache tags.
    $tids = [];
    foreach ($tags as $key => $tag) {
     if (str_starts_with($tag, 'taxonomy_term:')) {
     $tag_parts = explode(':', $tag);
     $tids[] = $tag_parts[1];
     unset($tags[$key]);
     }
    }

Next, check if any term IDs were collected, and, if so, retrieve the the corresponding vocabulary IDs:

    if ($tids) {
     // Reindex tags.
     $tags = array_values($tags);
    
     // Load vocabularies by term IDs.
     $vocabs = $this->database->select('taxonomy_term_data', 't')
     ->fields('t', ['vid'])
     ->condition('tid', $tids, 'IN')
     ->distinct()
     ->execute()
     ->fetchAll();
    }

Now, add `taxonomy_term_list` cache tags to the `$tags` array:

    // Add vocabulary cache tags.
    foreach ($vocabs as $vocab) {
     $tags[] = 'taxonomy_term_list:' . $vocab->vid;
    }

Finally, update the `Response`'s cacheable metadata:

    // Update cacheable metadata with modified set of cache tags.
    $response->getCacheableMetadata()->setCacheTags($tags);
    $event->setResponse($response);

Step 3: Limit the Scope of the Event Subscriber
-----------------------------------------------

With the code above implemented, taxonomy term cache tags will be consolidated for _all_ responses, not just the two examples from above. It would be prudent to scope the event subscriber's behavior. The example code below limits the event subscriber to update the response by route: 1) the _Article_ content type JSON:API collection endpoint, 2) the Page 1 display for a "Test View" view, and 3) the canonical Node route, but only for _Article_ nodes:

    public function onRespond(ResponseEvent $event) {
     if (!$event->isMainRequest()) {
     return;
     }
    
     $route_name = $this->currenntRouteMatch->getRouteName();
    
     // Check route name before proceeding with cache tag replacement.
     switch ($route_name) {
     // The Article content type JSON:API collection endpoint.
     case 'jsonapi.node--article.collection':
     // Contine to replace cache tags.
     break;
    
     // The Page 1 display for the Test View.
     case 'view.test_view.page_1':
     // Contine to replace cache tags.
     break;
    
     // The canonical route for Article nodes.
     case 'entity.node.canonical':
     $node = $this->currenntRouteMatch->getParameter('node');
     if ($node instanceof NodeInterface) {
     if ($node->bundle() == 'article') {
     // Contine to replace cache tags.
     break;
     }
     }
    
     // If none match, then return early and skip cache tag replacement.
     default:
     return;
     }
    

Complete Event Subscriber
-------------------------

Below is the complete Event Subscriber:

    <?php 
    
    namespace Drupal\term_cache_tag_consolidator\EventSubscriber;
    
    use Drupal\Core\Cache\CacheableResponseInterface;
    use Drupal\Core\Database\Connection;
    use Drupal\Core\Routing\CurrentRouteMatch;
    use Drupal\node\NodeInterface;
    use Symfony\Component\EventDispatcher\EventSubscriberInterface;
    use Symfony\Component\HttpKernel\Event\ResponseEvent;
    use Symfony\Component\HttpKernel\KernelEvents;
    
    /**
     * Replace individual taxonomy term cache tags with vocabulary tags.
     */
    class ResponseSubscriber implements EventSubscriberInterface {
    
     /**
     * Construct a ResponseSubscriber object.
     */
     public function __construct(
     private readonly Connection $database,
     private readonly CurrentRouteMatch $currenntRouteMatch,
     ) {}
    
     /**
     * {@inheritdoc}
     */
     public static function getSubscribedEvents(): array {
     // Execute just prior to
     // Drupal\Core\EventSubscriber\FinishResponseSubscriber.
     $events[KernelEvents::RESPONSE][] = ['onRespond', 17];
     return $events;
     }
    
     /**
     * Modify cache tags on designated cacheable responses.
     *
     * @param \Symfony\Component\HttpKernel\Event\ResponseEvent $event
     * The event to process.
     */
     public function onRespond(ResponseEvent $event) {
     if (!$event->isMainRequest()) {
     return;
     }
    
     $route_name = $this->currenntRouteMatch->getRouteName();
    
     // Check route name before proceeding with cache tag replacement.
     switch ($route_name) {
     // The Article content type JSON:API collection endpoint.
     case 'jsonapi.node--article.collection':
     // Contine to replace cache tags.
     break;
    
     // The Page 1 display for the Test View.
     case 'view.test_view.page_1':
     // Contine to replace cache tags.
     break;
    
     // The canonical route for Article nodes.
     case 'entity.node.canonical':
     $node = $this->currenntRouteMatch->getParameter('node');
     if ($node instanceof NodeInterface) {
     if ($node->bundle() == 'article') {
     // Contine to replace cache tags.
     break;
     }
     }
    
     // If none match, then return early and skip cache tag replacement.
     default:
     return;
     }
    
     $response = $event->getResponse();
    
     // Only modify cache tags when this is a cacheable response.
     if ($response instanceof CacheableResponseInterface) {
     $tags = $response->getCacheableMetadata()->getCacheTags();
    
     // Loop through cache tags, remove taxonomy terms, and build a list of
     // term IDs from removed cache tags.
     $tids = [];
     foreach ($tags as $key => $tag) {
     if (str_starts_with($tag, 'taxonomy_term:')) {
     $tag_parts = explode(':', $tag);
     $tids[] = $tag_parts[1];
     unset($tags[$key]);
     }
     }
    
     if ($tids) {
     // Reindex tags.
     $tags = array_values($tags);
    
     // Load vocabularies by term IDs.
     $vocabs = $this->database->select('taxonomy_term_data', 't')
     ->fields('t', ['vid'])
     ->condition('tid', $tids, 'IN')
     ->distinct()
     ->execute()
     ->fetchAll();
    
     // Add vocabulary cache tags.
     foreach ($vocabs as $vocab) {
     $tags[] = 'taxonomy_term_list:' . $vocab->vid;
     }
    
     // Update cacheable metadata with modified set of cache tags.
     $response->getCacheableMetadata()->setCacheTags($tags);
     $event->setResponse($response);
     }
     }
     }
    
    }

Additional Considerations
-------------------------

Please read below for additional considerations:

### Cacheability Debugging

By default, Drupal doesn't output cache tag headers. The Acquia Purge module does output an `X-Acquia-Purge-Tags` header, which is populated with cache tags. This header is used for cache invalidation at the load balancer layer and is removed before serving the response.

To see cache tags in development, enable `http.response.debug_cacheability_headers` in services.yml:

    # Cacheability debugging:
    #
    # Responses with cacheability metadata (CacheableResponseInterface instances)
    # get X-Drupal-Cache-Tags, X-Drupal-Cache-Contexts and X-Drupal-Cache-Max-Age
    # headers.
    #
    # For more information about debugging cacheable responses, see
    # https://www.drupal.org/developing/api/8/response/cacheable-response-interface
    #
    # Enabling cacheability debugging is not recommended in production
    # environments.
    # @default false
    http.response.debug_cacheability_headers: true

Drupal will now render a `X-Drupal-Cache-Tags` header.

### Opportunities for Enhancements

The example code above hardcodes the behavior of the event subscriber. Further enhancements could include the following:

1.  Exposing route matching conditions as configuration editable via a `SettingsForm`
2.  Exposing route matching conditions as configurable plugins (more extensible than option 1)
3.  Consolidating other content entity cache tags (e.g. nodes)

### Opportunity for Contribution

At present, there isn't a contrib module on Drupal.org that provides this functionality. This contrib gap represents an opportunity for members of the Drupal open source community to contribute back.