Drupal Got Cache Invalidation Right. The Rest of the Industry Is Catching Up.
Drupal's cache tag system is one of the better-designed cache invalidation models in modern web frameworks. A practitioner's look at why it works, where it breaks across system boundaries, and the mental model that travels to any stack.
Drupal Got Cache Invalidation Right. The Rest of the Industry Is Catching Up.
Drupal's cache tag system has been quietly solving one of the hardest problems in backend engineering for over a decade. Most developers in other stacks reinvent it badly, usually as some combination of TTL guessing, manual key invalidation, or "just blow away the whole cache." Next.js shipped revalidateTag in 2023 and reinvented Drupal cache tags from first principles, which is the clearest external validation Drupal's design has ever received.
This post is about why the abstraction works, where it breaks across system boundaries, and the mental model worth taking with you regardless of stack.
The Real Problem Cache Invalidation Solves
The cliche is that cache invalidation is one of the two hard problems in computer science. The cliche is not wrong, but it is incomplete. Cache invalidation is hard because it is actually three different problems stacked on top of each other:
Dependency tracking. When the underlying data changes, what cached values are now stale? This is the problem most engineers think about, and it is the easiest of the three.
Coordination across system boundaries. When the source of truth changes, every cache that holds a derived value needs to know. The application cache, the reverse proxy, the CDN, the browser, the downstream service. Each is a different system with its own invalidation API.
Consistency under concurrent change. When two writes happen close together, or when a read happens during invalidation, what does the user see? This is where most production cache bugs actually live.
Cache tags address the first problem elegantly, the second problem partially, and the third problem barely. That is more than most cache systems do.
How Drupal Cache Tags Actually Work
The core idea is simple. Every cached value is stored not just under a key, but with a list of dependency tags. When data changes, you invalidate by tag, not by key. Drupal finds every cached item that declared that tag as a dependency and marks it stale.
Tags are strings. They look like this:
node:123— depends on node 123user:5— depends on user 5taxonomy_term:42— depends on taxonomy term 42config:system.site— depends on site configurationnode_list— depends on any node being created or deletedhttp_response— depends on the response itself
When you save node 123, Drupal calls Cache::invalidateTags(['node:123']). Every cached item that declared node:123 as a dependency is now stale. A page that renders node 123 in its main region. A block that lists node 123 in a sidebar. A view that includes node 123 in a result set. All of them invalidate, and only the ones that depend on this specific node.
The elegant part is that you almost never declare tags manually. Drupal's render system bubbles them up automatically.
$build = [
'#theme' => 'node',
'#node' => $node,
'#cache' => [
'tags' => $node->getCacheTags(), // returns ['node:123']
'contexts' => ['user.permissions'],
'max-age' => Cache::PERMANENT,
],
];When this render array is included in a page, the page's cache metadata absorbs the tags. A page rendering 50 different entities accumulates 50 different tags. Any one of them changing invalidates the page, and only that page (plus anything else that depended on the same entity).
This is what Drupal calls render-array cacheability bubbling, and it is the secret sauce. You do not have to know what depends on what. The render system tracks it for you.
The Hard Part: Crossing the CDN Boundary
The cache tag model is elegant inside Drupal. The hard part is propagating it outward.
A real Drupal site does not just cache in the application layer. It caches in Varnish or Nginx in front of the application. It caches at the CDN edge in Cloudflare or Fastly. It might cache in a Redis layer between the application and the database. Each of these is a separate system with its own concept of invalidation, and each is a boundary where cache tags can fail to propagate.
Drupal's answer is to expose tags as response headers. Look at any cacheable response from a Drupal site and you will see something like:
X-Drupal-Cache-Tags: node:123 node_view user:5 user_view http_response renderedThis header is the bridge. A CDN that understands cache tags (Fastly via Surrogate-Key, Cloudflare Enterprise via Cache-Tag) can index its edge cache by these tags. When Drupal needs to invalidate node 123 at the edge, it sends a purge request to the CDN referencing the tag, and the CDN purges every cached response that carried that tag in its headers.
The full chain looks like this:
- Node 123 is saved
- Drupal invalidates
node:123in its application cache (cache tags table) - Drupal's purge module (or your custom code) sends a purge request to the CDN
- CDN purges every edge-cached response indexed by
node:123 - Next request rebuilds the page fresh, with new content, and re-caches it
This is conceptually clean. Operationally, it is where most invalidation bugs live. The purge request can fail. The CDN can have eventual-consistency lag. The Drupal-side invalidation can succeed while the CDN-side fails, leaving you with stale edge content and fresh origin content. The Purge module ecosystem exists precisely to manage this complexity, and it is non-trivial.
What Other Stacks Have Figured Out
The most striking evidence that Drupal got this right is what other ecosystems have built in the last few years.
Next.js shipped revalidateTag in late 2023. Watch what the API looks like:
// In a server component or route handler
fetch('https://api.example.com/posts/123', {
next: { tags: ['post:123'] }
});
// Later, when the post changes
revalidateTag('post:123');This is, almost exactly, Drupal's cache tag model with different syntax. Vercel did not invent this pattern. They rediscovered it because the underlying problem demands the same shape of solution. If you read the Next.js documentation alongside Drupal's cache API documentation, the design vocabulary is nearly identical.
Rails has Russian doll caching, which is an approximation of the same idea using cache key versioning. When a record changes, its updated_at changes, the cache key derived from it changes, and dependent caches naturally miss. This works but it is inverted: rather than invalidating, you change the lookup. Cleaner in some ways, harder to coordinate across system boundaries because the version is implicit.
Django has versioned cache keys and cache patterns based on signals. The signals approach is the closest equivalent to render-array bubbling, but it is opt-in and requires careful wiring. Most Django apps end up with TTL-based caching because the explicit dependency tracking is more work than developers want to do.
Most Node apps have no abstraction at all. They either set a TTL and accept staleness, or they blow away whole cache namespaces on writes, or they have no cache. The cache tag pattern exists in the ecosystem (libraries like node-cache-manager support tags) but it is not idiomatic.
Java/Spring has @CacheEvict annotations, which are manual per-method invalidations. This works at small scale and falls apart at any complexity because the developer has to remember every place a value is cached and add the right annotation. Drupal's render-array bubbling does this automatically.
The pattern across all these ecosystems is the same: the languages and frameworks that solve cache invalidation well converge on tag-based dependency tracking, whatever they call it. The ones that do not converge end up with cache bugs in production.
The Failure Modes Nobody Warns You About
I do not want to leave you with the impression that cache tags are a finished solution. They have specific failure modes that every senior Drupal practitioner has hit at least once.
Tag explosion. A page rendering hundreds of entities accumulates hundreds of tags. The cache tags table grows. Invalidations become expensive. The query that finds every cached item depending on node:123 becomes a hot path. On sites with millions of cache entries this can become its own bottleneck.
Tag misses. Render code that forgets to declare a dependency. The classic example: a custom block reads from a non-entity data source (an external API, a config value, a derived calculation) and forgets to call $build['#cache']['tags'][]. The page caches. The underlying data changes. The cache never invalidates. Content goes stale forever, or until you manually flush.
Cross-system race conditions. Drupal invalidates node:123 in its cache. The purge request to the CDN is queued. Meanwhile, a request hits the CDN, sees the old cached page, and the CDN refreshes from origin — which now has the new content. Fine. But sometimes the order inverts: CDN refresh happens before origin has finished the write, you re-cache the old content at the edge, and now you are stuck with stale edge content even though origin is fresh. The Drupal Purge module mitigates this but cannot eliminate it.
Thundering herd on mass invalidation. Invalidate node_list (every node-listing page on the site) and every concurrent visitor triggers a rebuild simultaneously. Without origin-side request coalescing or cache warming, your origin gets hammered. The fix is layered: stale-while-revalidate at the CDN, lock-based rebuilds at the application, request coalescing where supported. None of this is free.
Tag granularity tradeoffs. Too granular and you get tag explosion. Too coarse and you over-invalidate. Drupal defaults are usually right but custom code needs judgment. A custom block that depends on "any node of type article" can declare node_list (too coarse, invalidates on every node save) or it can declare node_type:article (more targeted) or it can enumerate every article it actually rendered (precise but tag-heavy). The right answer depends on your write patterns.
These are not reasons to avoid cache tags. They are reasons to respect them and to read the X-Drupal-Cache-Tags header when something is not invalidating the way you expect.
The Mental Model Worth Taking Elsewhere
The deepest lesson from Drupal's cache tag system is not "use cache tags." It is this:
Cache invalidation is fundamentally a dependency graph problem. Whatever system you are in, ask what your cached values depend on, make those dependencies explicit, and make them queryable.
If you are working in a stack that has tag-based invalidation built in (Drupal, Next.js, Fastly), use it well. If you are working in a stack without it, you are usually one of three things:
- Reimplementing tags badly (signals, cache key versioning, manual invalidation everywhere)
- Avoiding the problem (long TTLs, accept staleness, flush everything on writes)
- Building tags from scratch under a different name (surrogate keys, revalidation paths, evict groups)
Option 3 is often the right answer, but only if you know that is what you are doing. The trap is reimplementing tags by accident, with no design intent, and ending up with a cache invalidation system that nobody on the team can reason about because it was never named.
Closing Thought
The next time you are debugging a stale cache issue in any stack, ask three questions:
What does this cached value depend on? Are those dependencies explicit or implicit? When a dependency changes, what tells this cache to invalidate?
If you cannot answer all three quickly, you have a cache invalidation design problem, not a cache bug. Drupal's cache tag system is good because it forces you to answer all three questions every time you cache something. The frameworks catching up to it are doing so because answering those three questions is what good cache invalidation looks like, in any language and any stack.
Drupal practitioners have been doing this for over a decade. The rest of the industry is finally building the same primitives. That is worth noticing, both as validation of the design and as a reminder that some of the patterns Drupal pioneered are quietly becoming web architecture canon.
If you have hit a cache tag failure mode I did not cover, I would be curious which one. The honest production scars are harder to find in writing than the success stories.
Read next
- May 25, 2026
Stop Making Users Pay for Work Your CI Pipeline Could Do
Most slow Drupal sites are not slow because the code is wrong. They are slow because expensive work runs at request time when it could have run in the CI pipeline instead. Cold caches, image derivatives, search indexes. The pattern repeats. The fix is the same in every case: move the cost off your users and onto a controlled environment where nobody is waiting. This is a principle, not a tip. Three examples below to make it concrete.
- May 11, 2026
Drupal's Queue API is the Most Underused Feature in Core
Most Drupal teams reach for cron, Batch API, or external job runners when Queue API would solve the problem with less code and fewer moving parts. Three patterns where Queue API earns its place, with snippets and architecture diagrams.
- May 06, 2026
Anatomy of a Drupal Performance Crisis: When MySQL Is on Fire, More MySQL Isn't the Fix
A forensic walkthrough of stabilizing a high-traffic Drupal 9 platform under sustained bot pressure. Why the obvious fix was the wrong fix, and what actually worked.