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 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 for more details.
One common cause is content entity 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.
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:
isMainRequest()) { return; } }
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);
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; }
Below is the complete Event Subscriber:
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); } } }}
Please read below for additional considerations:
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 falsehttp.response.debug_cacheability_headers: true
Drupal will now render a X-Drupal-Cache-Tags
header.
The example code above hardcodes the behavior of the event subscriber. Further enhancements could include the following:
SettingsForm
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.
?>If this content did not answer your questions, try searching or contacting our support team for further assistance.
Fri Sep 12 2025 06:11:30 GMT+0000 (Coordinated Universal Time)