Cache tags vs cache contexts: the mental model that ends “why won't my block update?”
The single most common Drupal bug I've watched people chase — myself included — is "I changed the data and the block won't update." The second most common is its evil twin: "user B is seeing user A's content." Both come from the same misunderstood corner of Drupal, and once the mental model clicks, a whole class of render bugs just stops happening. Here's the model, in the plainest terms I can manage.
Three things, three different jobs
Every render array carries a #cache key with three properties, and the entire trick is that they answer three different questions:
- Cache tags — "when does this become wrong?" They're data dependencies.
node:5,node_list,config:system.site. When that data changes, anything tagged with it gets purged. Tags are about invalidation. - Cache contexts — "what does this vary by?" They're Drupal's version of the HTTP
Varyheader.user,user.roles,url.path,languages,theme. Each relevant context means a separate cached copy. Contexts are about variation. - Cache max-age — "for how long, on the clock?" Seconds. And here's the trap:
max-age: 0means uncacheable, not "permanent." Permanent isCache::PERMANENT(which is-1). That naming has burned more people than I can count.
The whole post in one line
Stale content = you forgot a cache tag. Content leaking between users = you forgot a cache context. Tags control when a cached thing dies; contexts control how many copies exist. Different bug, different axis, different fix.
That's it. That's the model. If your block shows old data, you didn't declare what data it depends on — add the tag. If your block shows the wrong person's data, you didn't declare what it varies by — add the context. Reaching for max-age: 0 "fixes" both by turning caching off, which is why it's the wrong tool almost every time.
Tags: single thing vs a list
The rule of thumb that covers most cases: render one entity, use its node:N tag; render a list or query of entities, use the _list tag. The list case is the one people miss. If you write a custom query that returns "the 5 latest articles," an entityQuery attaches nothing for you — so when a new article is published, your block doesn't know it should refresh. You need node_list (or the more surgical node_list:article) so a save fires Cache::invalidateTags() and purges it.
Contexts: pick the cheapest one that's still correct
Contexts have a hierarchy, and coarser ones mean fewer cached copies and a better hit rate. If your output differs by role (say, an "edit" link for editors), use user.roles — not user. Varying by user creates a separate cache entry for every single account; varying by user.roles creates one per role. Both are "correct," but one quietly destroys your cache hit rate. Use the most specific context that's still accurate.
How it bubbles (and how max-age poisons a page)
Cacheability bubbles up. A page's effective cache metadata is the union of its own plus every descendant's — tags merge, contexts merge and simplify, and max-age merges as the minimum. That last one matters: a single max-age: 0 deep in the tree drags the entire page down to uncacheable. It's a silent performance killer.
The correct escape hatch for a genuinely dynamic fragment — a "Hello, Alice," a CSRF-tokened form — is a #lazy_builder / placeholder. Drupal renders that bit separately, so its per-user context or max-age: 0 doesn't spoil the cacheable 99% of the page around it. That's how core serves a fully cached page with a personalised corner.
The code
A render array declaring all three:
$build = [
'#theme' => 'my_module_featured',
'#items' => $items,
'#cache' => [
'tags' => ['node_list:article', 'config:my_module.settings'],
'contexts' => ['user.roles', 'languages:language_interface'],
'max-age' => 3600,
],
];
And when you render entities or run queries manually in a controller or service — where nothing is attached for you — build the metadata explicitly and let each entity contribute its own:
use Drupal\Core\Cache\CacheableMetadata;
$cache = new CacheableMetadata();
$cache->addCacheContexts(['user.roles']);
foreach ($articles as $node) {
$cache->addCacheableDependency($node); // pulls in node:N, its contexts, max-age
}
$cache->addCacheableDependency($this->configFactory->get('my_module.settings'));
$cache->applyTo($build);
addCacheableDependency() is the one to internalise: it's future-proof and correct by construction, far better than hardcoding node:5. It's exactly the pattern my own blog_seo module uses when it attaches metadata for the JSON-LD it injects — the SEO output depends on the node, so it carries the node's cacheability.
The debugging trick
When you're staring at a stale block, turn on http.response.debug_cacheability_headers: true in services.yml. Now every response tells you, in X-Drupal-Cache-Tags and X-Drupal-Cache-Contexts, exactly what bubbled up. Nine times out of ten the missing tag or the missing context is right there in the header you expected to see it in — and isn't.