Fine-grained caching in Drupal with cache tags and contexts
Learn how Drupal's cache tags and contexts let you invalidate only what changed, keeping personalized blocks correct while cutting render time on content-heavy sites.
31 Jul 2025, 13:08 UTC

Problem: slow pages on content-heavy sites with personalized blocks
When a Drupal site renders many dynamic blocks—recent news, user-specific menus, language-aware teasers—each request often ends in a cache miss. The page is rebuilt from scratch, which adds noticeable latency, especially under traffic spikes. The goal is to keep personalized output correct while reusing as much rendered markup as possible.
Thesis: cache tags and contexts let Drupal invalidate only what changed
By attaching metadata to render arrays, Drupal can track which pieces of output depend on which data. When that data changes, only the tagged items are cleared. Cache contexts then vary the output by factors like user role, language, or URL without breaking the cache. Together they give fine-grained invalidation that preserves personalization.
How cache tags work
Every render array can include a #cache['tags'] property. Tags are strings that identify the data the markup relies on—for example, an entity ID or a list of nodes. When a matching entity is saved, Drupal finds all cache items carrying that tag and removes them from the cache bin. Everything else stays cached.
How cache contexts work
Cache contexts describe the dimensions along which output may differ. Adding a context to #cache['contexts'] tells Drupal to store a separate cache variant for each value. Common contexts include user.permissions, languages:language_interface, and url.path. If a needed context is missing, the same cached variant may be served to users who should see different content.
Worked example: a language-aware recent news block
Below is a simple custom block that lists the three most recent promoted articles, filtered by the current interface language. It assumes Drupal 10.x and a custom module named my_module. This is a reference example, not tested output—verify it in your own environment.
// web/modules/custom/my_module/src/Plugin/Block/RecentNewsBlock.php
namespace Drupal\my_module\Plugin\Block;
use Drupal\Core\Block\BlockBase;
use Drupal\Core\Language\LanguageInterface;
/**
* Provides a 'Recent news' block.
*
* @Block(
* id = "recent_news",
* admin_label = @Translation("Recent news"),
* category = @Translation("Custom")
* )
*/
class RecentNewsBlock extends BlockBase {
public function build() {
$language = \Drupal::languageManager()
->getCurrentLanguage(LanguageInterface::TYPE_INTERFACE)
->getId();
$nids = \Drupal::entityQuery('node')
->accessCheck(TRUE)
->condition('status', 1)
->condition('promote', 1)
->condition('langcode', $language)
->sort('created', 'DESC')
->range(0, 3)
->execute();
$nodes = \Drupal::entityTypeManager()
->getStorage('node')
->loadMultiple($nids);
$items = [];
foreach ($nodes as $node) {
$items[] = $node->toLink()->toRenderable();
}
$build = [
'#theme' => 'item_list',
'#items' => $items,
'#title' => $this->t('Recent news'),
];
// Invalidate when any node changes (list tag covers all nodes).
$build['#cache']['tags'] = ['node_list'];
// Store a separate variant per interface language.
$build['#cache']['contexts'] = ['languages:language_interface'];
return $build;
}
}
Key points:
- The query filters nodes by the current interface language, so the output genuinely varies by language.
- The
node_listtag is a built-in list tag: whenever any node is inserted, updated, or deleted, cache items carrying it are invalidated—including this block—without flushing unrelated caches. - The
languages:language_interfacecontext ensures French and English visitors each get their own cached variant.
Verifying tags and contexts with debug headers
Drupal can emit response headers listing the cacheability metadata of a page. On a local development site (DDEV, Lando, or similar), enable them as follows. You need write access to the site's settings files and permission to run Drush.
- In
sites/default/settings.php(orsettings.local.php), add:$settings['container_yamls'][] = DRUPAL_ROOT . '/sites/development.services.yml'; - Confirm
sites/development.services.ymlcontains:parameters: http_response_debug_cacheability_headers: true - Rebuild the container from the project root:
drush cr - Request the page containing the block and inspect the headers:
curl -sI https://your-site.ddev.site/page-with-block | grep -i x-drupal-cache
You should see X-Drupal-Cache-Tags and X-Drupal-Cache-Contexts headers that include your tag and the language context. Then promote or edit a node and reload: the block should reflect the change without a full cache flush. Note that enabling these headers is a development-only change; do not leave them on in production, since they expose internal metadata.
Trade-off: over-tagging and missing contexts
Cache tags are not free. Attaching a unique tag to every render array inflates the cache bins and can trigger frequent invalidations, erasing the performance gain. Tag at the level of the data that actually changes—a list tag for a query result, for instance, rather than inventing a tag per block instance.
The opposite mistake is omitting a context. A block that shows edit links but lacks user.permissions may serve the editor's variant to anonymous visitors. Audit what the output actually depends on—language, role, path, query arguments—and add a matching context for each dimension.
Actionable closing: audit, tag, verify, measure
- List the custom blocks and render arrays that depend on entity data, user state, or the URL.
- Add appropriate
#cache['tags'](entity list tags are a good default for query-driven output). - Add
#cache['contexts']for every dimension that changes the output. - Enable the debug headers locally, confirm the metadata appears, and change tagged data to verify targeted invalidation.
- Measure response times before and after with a load tool such as
aborhey, and watch cache hit rates rather than assuming improvement.
Applied systematically, cache tags and contexts keep personalized content accurate while letting Drupal reuse most of the rendered page—faster loads without stale output.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.