Skip to main content
Skip to main content

Hi, I'm Karim Boudjema. I'm a Senior Backend Developer living in Montréal, Canada, passionate about Drupal, AI and automated testing.

The 'node_list' Cache Tag Pitfall in Drupal 11 Views

The node_list cache tag is generated automatically whenever we build a view that lists nodes. It invalidates the cache of every view that lists any kind of node (page, article, and so on) as soon as we perform a CUD operation (create, update, delete) on any node at all.

As a default that's a very good invalidation strategy: change one node and every node listing refreshes to reflect it. Yes, the Drupal Cache API is genuinely great. Thanks to all the contributors, like Wim Leers, who made this possible.

So far so good, but... what happens on a high-traffic site with dozens of different node bundles and hundreds of views listing them? If we edit a single node (say a page), the cache of every view listing pages is invalidated, but so is the cache of every view listing articles, and every other bundle. Change one page and the article listings are thrown away too, even though no article changed. On a busy site with frequent edits, like an online newspaper, that becomes a real performance problem.

How can we invalidate the view caches more precisely, only for nodes of the same type?

We'll do it in two steps:

  1. place a custom cache tag on each view for the node type it lists, like node:type:page for views listing pages, node:type:article for views listing articles, and so on;
  2. invalidate that custom cache tag when a CUD operation happens on that specific node type, with a hook_node_presave().

1. Add custom cache tags with the Views Custom Cache Tags module

Luckily, the community already solved the first half for us: the Views Custom Cache Tags contrib module lets us set a custom cache tag on any view. In our case we'll set one per node type:

  • node:type:article on views listing articles,
  • node:type:page on views listing pages,
  • node:type:<your-node-type> and so on.

First, download and install the module. Drupal Console is long gone, so we use Composer and Drush:

composer require drupal/views_custom_cache_tag
drush en views_custom_cache_tag

Then create two block views to try it out, one listing five articles (call it Block Articles) and one listing five pages (Block Pages). Now set the custom cache tag on the articles view: edit the view, open the Advanced section, and under Caching switch it to Tag based. Then choose Custom Tag based and enter the tag for this node type: since this view lists articles, we enter node:type:article. For a view listing another bundle we'd enter node:type:<node-type>. Don't forget to click Apply, then save the view. Do the same on the pages view with node:type:page.

Once every node-listing view carries its custom cache tag, we move on to the second step: invalidating those tags when they need it.

2. Invalidate the custom cache tags with a node presave hook

Now we invalidate the custom cache tag whenever a CUD operation happens on a specific node type, with hook_node_presave(). And yes, there are still hooks in Drupal 11, but the way we write them has changed. In Drupal 8 this lived as a procedural function in a .module file; in Drupal 11 the modern form is an object-oriented hook: a method on a class under src/Hook/, tagged with the #[Hook] PHP attribute. The original example lives at github.com/KarimBoudjema/Drupal8-invalidate-custom-cache-tags; here it is rewritten for Drupal 11.

Create the module (it depends on the contrib module):

drush generate module
drush en kb_invalidate_custom_cache_tags

Then add the hook class at src/Hook/NodeCacheHooks.php:

<?php

declare(strict_types=1);

namespace Drupal\kb_invalidate_custom_cache_tags\Hook;

use Drupal\Core\Cache\Cache;
use Drupal\Core\Hook\Attribute\Hook;
use Drupal\node\NodeInterface;

/**
 * Invalidates a per-bundle custom cache tag when a node is saved.
 */
final class NodeCacheHooks {

  /**
   * Implements hook_ENTITY_TYPE_presave() for the node entity type.
   */
  #[Hook('node_presave')]
  public function nodePresave(NodeInterface $node): void {
    // Build the per-bundle tag: node:type:article, node:type:page, ...
    $cacheTag = 'node:type:' . $node->bundle();
    // Mark it invalid in every cache bin.
    Cache::invalidateTags([$cacheTag]);
  }

}

This hook fires every time a node is created or updated. We read the node's bundle with $node->bundle() (article, page, and so on) and build the tag node:type:<bundle>, the exact tag we placed on the matching views. Then Cache::invalidateTags() marks that one tag invalid across all cache bins, so only the views carrying it are rebuilt.

So if we insert or update an article, the tag is node:type:article and only the views listing articles are invalidated. Views with node:type:page stay valid. This is exactly what we were after. (Cache::invalidateTags() is a static shortcut; in a class that already injects services you could use the cache_tags.invalidator service instead.)

Recap. To stop every node view from being invalidated on any node change, we replaced the broad node_list cache tag with a narrower custom tag, node:type:<node-type>. The Views Custom Cache Tags module lets us place that tag on each view by the bundle it lists (node:type:article, node:type:page, and so on). Then we invalidate the right tag on save with a hook_node_presave(), written the Drupal 11 way as an object-oriented #[Hook] class.

Voilà! If you have another strategy for the node_list problem, please share it with us in the comments.

More info