Caching
Drupal caches aggressively at every layer, from individual render arrays to whole anonymous pages, and stays correct while doing it. This page explains the metadata system that makes that possible, so you can write code that is fast by default and never serves stale or leaked output.
What and why
Section titled “What and why”Caching has two competing requirements. For caching to be effective, items must be invalidated as rarely as possible. For caching to be correct, every item must be invalidated the moment the data it was computed from changes, and a cached item must never be served to a visitor it was not computed for.
Most systems pick one. A short timeout is correct-ish but ineffective; a long timeout is effective but wrong. Drupal solves the problem structurally instead: every piece of computed output carries cacheability metadata declaring what it depends on, what it varies by, and how long it can live. With that declaration, the cache layers (the render cache, the dynamic page cache, the anonymous page cache, and external proxies and CDNs) can each make the right call without knowing anything about your code.
The payoff for you as a developer: you never write invalidation logic for consumers you do not know about. You declare metadata once, at the point where the data is used, and every cache above you, including a CDN that understands cache tags, invalidates correctly.
Mental model
Section titled “Mental model”Every piece of computed output answers three questions, and the three answers are the whole API:
- Cache tags answer “what data was this computed from?” A tag names a thing:
node:42,user:3,config:system.site,node_list. When the thing changes, everything tagged with it is invalidated, everywhere, immediately. - Cache contexts answer “what circumstances does this vary by?” A context names a circumstance of the request: the interface language, the user’s permissions, the URL path. One cached item exists per combination of context values.
- Max-age answers “how long can this stay valid regardless?” Almost always
Cache::PERMANENT, because tags handle change; a finite max-age is for data with a real expiry, like an external API response.0means uncacheable.
Hold on to the invariant: a tag names a thing, a context names a circumstance, a max-age names a deadline. Misfiling one as another is the root of nearly every caching bug.
To pick the right metadata for a dependency, ask what kind of dependency it is. If the output is computed from stored data such as a node or a config object, use a cache tag like node:42. If it varies by a circumstance of the request such as language, role, or path, use a cache context like user.permissions. If its validity decays purely with time, such as an external feed, use a finite max-age. A dependency can need more than one of these at once.
In render arrays, the three live in the #cache property, together with optional keys that mark the array as its own entry in the render cache:
/** * Builds a render array that declares its own cacheability. * * @param \Drupal\node\NodeInterface $node * The node the badge describes. * @param int $count * The current RSVP count for the node. * * @return array<string, mixed> * The render array. */public function buildBadge(NodeInterface $node, int $count): array { return [ '#theme' => 'concepts_systems_badge', '#label' => $node->label(), '#count' => $count, '#cache' => [ // Keys identify this thing: only set them where output should be // cached as its own entry in the render cache. 'keys' => ['concepts_systems_badge', (string) $node->id()], // Contexts name the circumstances the output varies by. 'contexts' => ['languages:language_interface'], // Tags name the data the output depends on; saving the node // invalidates every cache entry tagged with them. 'tags' => $node->getCacheTags(), // Valid until the tags say otherwise. 'max-age' => Cache::PERMANENT, ], ];}Anything that implements \Drupal\Core\Cache\CacheableDependencyInterface can hand you its metadata: entities, config objects, access results. \Drupal\Core\Cache\CacheableMetadata merges metadata from several sources and applies it to a render array, so the common pattern is “collect from everything you touched, merge, apply”.
Cache tags
Section titled “Cache tags”Tags follow the convention prefix:suffix. You rarely construct them by hand: call getCacheTags() on the entity or config object the output came from, and getListCacheTags() on an entity type when the output is a listing that must refresh when any item is added or removed.
Invalidation is one call, on the cache_tags.invalidator service (\Drupal\Core\Cache\CacheTagsInvalidatorInterface). It fans out to every cache bin and to every other registered invalidator, which is how a CDN module learns to purge the same content. Entities do this for you on save and delete. For your own data, invalidate your own tag where the data is written, the way the tutorial’s storage service does:
Saving a node fires its node:42 tag through the cache tags invalidator. The invalidator fans the tag out to every cache bin and to every other registered invalidator, including a CDN purger. Each cache drops only the entries tagged node:42. You do not clear all caches to publish content.
<?php
declare(strict_types=1);
namespace Drupal\event_rsvp;
use Drupal\Component\Datetime\TimeInterface;use Drupal\Core\Cache\CacheTagsInvalidatorInterface;use Drupal\Core\Database\Connection;
/** * Reads and writes RSVP records using the Database API. * * Registered as the "event_rsvp.storage" service in event_rsvp.services.yml. */class RsvpStorage {
/** * Constructs the RSVP storage. * * @param \Drupal\Core\Database\Connection $database * The active database connection. * @param \Drupal\Component\Datetime\TimeInterface $time * The time service. * @param \Drupal\Core\Cache\CacheTagsInvalidatorInterface $cacheTagsInvalidator * The cache tags invalidator. */ public function __construct( protected readonly Connection $database, protected readonly TimeInterface $time, protected readonly CacheTagsInvalidatorInterface $cacheTagsInvalidator, ) {}
/** * Records an RSVP for a node. * * @param int $nid * The event node ID. * @param int $uid * The attendee's user ID, 0 for anonymous visitors. * @param string $mail * The attendee's email address. * * @return int * The ID of the new RSVP record. */ public function add(int $nid, int $uid, string $mail): int { $id = $this->database->insert('event_rsvp') ->fields([ 'nid' => $nid, 'uid' => $uid, 'mail' => $mail, 'created' => $this->time->getRequestTime(), ]) ->execute(); // Anything rendered from this data — like the RSVP count block — tags // itself with this cache tag, so invalidating it here keeps every // cached copy accurate. See RsvpStorage::cacheTag(). $this->cacheTagsInvalidator->invalidateTags([static::cacheTag($nid)]); return (int) $id; }
/** * Checks whether an email address already responded to a node. * * @param int $nid * The event node ID. * @param string $mail * The email address to check. * * @return bool * TRUE if an RSVP exists for this node and email address. */ public function exists(int $nid, string $mail): bool { $count = $this->database->select('event_rsvp', 'r') ->condition('r.nid', $nid) ->condition('r.mail', $mail) ->countQuery() ->execute() ->fetchField(); return (int) $count > 0; }
/** * Counts the RSVPs recorded for a node. * * @param int $nid * The event node ID. * * @return int * The number of RSVPs. */ public function countForNode(int $nid): int { $count = $this->database->select('event_rsvp', 'r') ->condition('r.nid', $nid) ->countQuery() ->execute() ->fetchField(); return (int) $count; }
/** * Loads all RSVP records for a node, newest first. * * @param int $nid * The event node ID. * * @return object[] * RSVP rows with id, nid, uid, mail and created properties. */ public function listForNode(int $nid): array { return $this->database->select('event_rsvp', 'r') ->fields('r', ['id', 'nid', 'uid', 'mail', 'created']) ->condition('r.nid', $nid) ->orderBy('r.created', 'DESC') ->execute() ->fetchAll(); }
/** * Deletes every RSVP recorded for a node. * * @param int $nid * The event node ID. */ public function deleteForNode(int $nid): void { $this->database->delete('event_rsvp') ->condition('nid', $nid) ->execute(); $this->cacheTagsInvalidator->invalidateTags([static::cacheTag($nid)]); }
/** * Returns the cache tag for one node's RSVP data. * * @param int $nid * The event node ID. * * @return string * The cache tag, for example "event_rsvp:node:42". */ public static function cacheTag(int $nid): string { return 'event_rsvp:node:' . $nid; }
}Cache contexts
Section titled “Cache contexts”Contexts are not free-form strings. Each one is a service tagged cache.context whose class implements \Drupal\Core\Cache\Context\CacheContextInterface and converts a circumstance of the current request into a cache key fragment. Drupal core ships these contexts (defined in core/lib/Drupal/Core/Cache/Context/):
| Context | Varies by |
|---|---|
ip | The client IP address. |
protocol_version | The HTTP protocol version. |
headers / headers:NAME | All request headers, or one named header. |
cookies / cookies:NAME | All cookies, or one named cookie. |
session | The session, for output that differs per session. |
session.exists | Whether a session exists at all. Anonymous visitors can have sessions too; vary by user.roles:authenticated for login state. |
request_format | The request format (HTML, JSON, …). |
exception_status_code | The status code on error pages. |
url | The entire URL: the most granular URL context. |
url.site | The site (scheme, host, and base path). |
url.path | The path, ignoring the query string. |
url.path.parent | The path minus its last segment. |
url.path.is_front | Whether the current path is the front page. |
url.query_args / url.query_args:KEY | All query arguments, or one named argument. |
url.query_args.pagers / url.query_args.pagers:ID | Pager position(s). |
route | The route and its parameters. |
route.name | The route name only, ignoring parameter values. |
route.menu_active_trails:MENU | The active trail in the named menu. |
user | The individual user account: the most granular user context. |
user.permissions | The account’s full permission set. |
user.roles / user.roles:ROLE | The account’s roles, or possession of one role. |
user.is_super_user | Whether the account is user 1. |
languages / languages:TYPE | The negotiated language(s); languages:language_interface is the common one. |
theme | The active theme. |
timezone | The user’s time zone. |
Modules add their own: the node module provides user.node_grants:OPERATION for grant-based access, and the Workspaces module provides workspace. The forms with a parameter (user.roles:authenticated, url.query_args:page) are calculated contexts: passing the parameter narrows the variation, which keeps the number of cached copies down.
Contexts are hierarchical along the dots. url.path is contained in url; user.permissions is contained in user. The cache system exploits this to optimize, and you should exploit it to choose the least granular context that is still correct: vary by user.permissions rather than user whenever what you actually depend on is what the user may do, not who they are.
Three contexts are so universal that the renderer adds them to every render-cached item: languages:language_interface, theme, and user.permissions (the required_cache_contexts renderer setting).
Max-age
Section titled “Max-age”Max-age is a blunt instrument and the last resort of the three. Use it when validity genuinely decays with time (an external feed fetched without webhooks) or to mark something uncacheable (max-age: 0) when no tag or context can describe it. Do not use it as a “refresh interval” for entity-derived data; tags already do that with zero staleness.
One sharp edge to know: the three page-level layers treat max-age differently.
- The internal page cache serves anonymous requests without a session. It stores whole responses permanently, relies on cache tags, and deliberately ignores bubbled max-age.
- The dynamic page cache serves everyone. It honors max-age, and it caches the page with lazy-builder placeholders left in, filling them in on every request.
- BigPipe streams those placeholders after the rest of the page, but only for requests that have a session. For a sessionless anonymous visitor the placeholders render inline, and the internal page cache stores the finished result.
So a lazy builder keeps a fragment fresh for logged-in users, but not for anonymous ones. If part of a page must never be cached for anonymous users, the response has to opt out of the internal page cache: call trigger() on the page_cache_kill_switch service, set the route option no_cache: TRUE, or add a page cache response policy. Usually the better move is to rethink the design first. The comments in the page_cache module’s middleware spell this out.
Bins, backends, and the two delete operations
Section titled “Bins, backends, and the two delete operations”Cached data lives in bins, each a service named cache.BIN implementing \Drupal\Core\Cache\CacheBackendInterface: default for miscellaneous data, bootstrap for early, rarely changing data, render for HTML fragments, data for context-dependent data, discovery for plugin and YAML discovery, and memory for in-request static caching. Where a bin physically stores data (database, Redis, or APCu, PHP’s local in-memory cache) is swappable per bin in settings.php without code changes.
Using a bin directly is the low-level API, for expensive computations that are not render arrays:
/** * Caches the result of an expensive calculation in a cache bin. * * @param \Drupal\node\NodeInterface $node * The node to summarize. * * @return array<string, int> * Statistics about the node, keyed by statistic name. */public function nodeStatistics(NodeInterface $node): array { $cid = 'docs_snippets:statistics:' . $node->id();
// A hit returns an object whose data property holds what set() stored. if (($cache = $this->cacheDefault->get($cid)) && is_array($cache->data)) { return $cache->data; }
$statistics = $this->calculateStatistics($node);
// Store permanently, tagged with the node's cache tags: the entry is // invalidated the moment the node is saved or deleted, with no timer // involved. $this->cacheDefault->set($cid, $statistics, Cache::PERMANENT, $node->getCacheTags());
return $statistics;}The backend distinguishes deleting (the item is gone) from invalidating (the item is marked stale but retrievable with $allow_invalid, useful to serve stale data while one process recomputes, protecting against stampedes). Tag invalidation is the soft kind.
Variations and redirects
Section titled “Variations and redirects”A plain cache backend maps one ID to one item, but contexts mean one logical item has many variants, and which contexts apply may itself depend on the variant. \Drupal\Core\Cache\VariationCache wraps a backend and solves this with cache redirects: lightweight entries that record “this item also varies by X, look again over there”. The render cache and the access policy processor are both built on it. You will rarely touch it directly, but knowing it exists explains the redirect entries you will meet when inspecting cache tables.
Cache prewarming Added in 11.2
Section titled “Cache prewarming ”After a full cache clear, the first requests race to rebuild the same expensive caches. Services that implement \Drupal\Core\PreWarm\PreWarmableInterface (the element info manager, the entity field manager, and others) can be rebuilt proactively by the cache_prewarmer service, which warms them in randomized order so concurrent callers warm different caches instead of duplicating work. Core does not call it on its own during requests; the installer and Drush (drush cache:warm, which drush deploy runs) do. If your module owns an expensive discovery-style cache, implement PreWarmableInterface to opt in; with autoconfigure on, the cache_prewarmable tag is added for you.
How core uses it
Section titled “How core uses it”-
Entities are cacheable dependencies. Every entity carries
getCacheTags()(node:42) and entity types carry list tags (node_list); saving an entity invalidates its tags automatically. This is why a node save updates every view, block, and page that rendered it, with no module code involved. -
Blocks declare their variations. The tutorial’s RSVP block varies by route and depends on the node and the RSVP data; it states exactly that and nothing more:
web/modules/custom/event_rsvp/src/Plugin/Block/RsvpCountBlock.php <?phpdeclare(strict_types=1);namespace Drupal\event_rsvp\Plugin\Block;use Drupal\Core\Block\Attribute\Block;use Drupal\Core\Block\BlockBase;use Drupal\Core\Cache\Cache;use Drupal\Core\Plugin\ContainerFactoryPluginInterface;use Drupal\Core\Routing\RouteMatchInterface;use Drupal\Core\StringTranslation\TranslatableMarkup;use Drupal\event_rsvp\RsvpStorage;use Drupal\node\NodeInterface;use Symfony\Component\DependencyInjection\ContainerInterface;/*** Shows how many people have responded to the event on this page.** The #[Block] attribute replaces the old @Block annotation: plain PHP* that your IDE and static analysis understand.*/#[Block(id: 'event_rsvp_count',admin_label: new TranslatableMarkup('RSVP count'),category: new TranslatableMarkup('Event RSVP'),)]final class RsvpCountBlock extends BlockBase implements ContainerFactoryPluginInterface {/*** Constructs the RSVP count block.** @param array $configuration* The plugin configuration.* @param string $plugin_id* The plugin ID.* @param mixed $plugin_definition* The plugin definition.* @param \Drupal\event_rsvp\RsvpStorage $rsvpStorage* The RSVP storage service.* @param \Drupal\Core\Routing\RouteMatchInterface $routeMatch* The current route match, used to find the node being viewed.*/public function __construct(array $configuration,string $plugin_id,mixed $plugin_definition,protected readonly RsvpStorage $rsvpStorage,protected readonly RouteMatchInterface $routeMatch,) {parent::__construct($configuration, $plugin_id, $plugin_definition);}/*** {@inheritdoc}*/public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {return new static($configuration,$plugin_id,$plugin_definition,$container->get('event_rsvp.storage'),$container->get('current_route_match'),);}/*** {@inheritdoc}*/public function build(): array {$node = $this->getNode();if (!$node) {return [];}$count = $this->rsvpStorage->countForNode((int) $node->id());return ['#markup' => $this->formatPlural($count,'1 person is attending.','@count people are attending.',),];}/*** {@inheritdoc}*/public function getCacheContexts(): array {// The output depends on which page (route) the block appears on:// a different node means a different count.return Cache::mergeContexts(parent::getCacheContexts(), ['route']);}/*** {@inheritdoc}*/public function getCacheTags(): array {$tags = parent::getCacheTags();if ($node = $this->getNode()) {// Invalidate the cached block whenever the node is saved, or when// RsvpStorage invalidates its custom tag after a new RSVP.$tags = Cache::mergeTags($tags, $node->getCacheTags());$tags = Cache::mergeTags($tags, [RsvpStorage::cacheTag((int) $node->id())]);}return $tags;}/*** Returns the node from the current route, if there is one.*/protected function getNode(): ?NodeInterface {$node = $this->routeMatch->getParameter('node');return $node instanceof NodeInterface ? $node : NULL;}} -
Access results carry cacheability.
\Drupal\Core\Access\AccessResult::allowedIfHasPermission()adds theuser.permissionscontext automatically, so an access decision cached into a page keeps the page varying correctly. The access concept page builds on this. -
Permissions themselves are cached with the same invariant. The
\Drupal\Core\Session\AccessPolicyProcessorstores each user’s calculated permissions in aVariationCacheunder the contexts the policies declare, tagged for targeted invalidation.
Common misconceptions
Section titled “Common misconceptions”-
“My change is not showing, so I need to clear all caches.” On a production site with correct metadata, you never clear caches to publish content; tags invalidate exactly what changed. Reach for
drush cache:rebuildduring development (it rebuilds discovery caches that only change when code changes), and when output sticks around longer than it should, find the missing tag instead of clearing harder. Debug a Drupal site shows how to inspect theX-Drupal-Cache-TagsandX-Drupal-Cache-Contextsresponse headers. -
“
max-age: 0keeps this off cached pages.” Not for anonymous traffic: the internal page cache ignores max-age by design and trusts tags. Max-age zero bubbles up and disables the dynamic page cache for the response, but the anonymous page cache will still serve a stored copy until a tag invalidates it. -
“Tags and contexts are interchangeable ways to vary output.” They answer different questions. If you vary by something that is a property of the request (language, role, query argument), that is a context. If you depend on stored data that changes over time (a node, a config object), that is a tag. Using a tag where you need a context leaks one visitor’s output to another; using a context where you need a tag serves stale data forever.
-
“I’ll vary by
userto be safe.” Theusercontext creates one cached copy per account and can make a cache useless on a site with many users. It also hides the real dependency. Ask what the output actually depends on; it is almost alwaysuser.permissionsoruser.roles. -
“My code is not cached, so I can skip the metadata.” Your render array ends up inside parents that are cached (the dynamic page cache, if nothing else). Metadata is not about whether you cache; it is a declaration that must be correct wherever the output lands. Omitting a context on an uncached array poisons every cache above it.
-
“Caching is something I add at the end.” Cacheability is part of the return value’s contract, like its type. Retrofitting contexts onto a finished feature is where leaks come from; declare metadata at the moment you touch a dependency.
Where to go next
Section titled “Where to go next”- Performance and caching for operating the cache layers in production: page cache, dynamic page cache, BigPipe, and CDNs.
- Debug a Drupal site for inspecting cacheability headers and finding missing tags.
- Render API and the theming pipeline for how metadata bubbles through render arrays.
- The
Cache APItopic on api.drupal.org for the complete backend and bin reference.
Was this page helpful?
Drupal is a registered trademark of Dries Buytaert.