Drupal Render Caching as an Architecture Decision: Requirements, Boundaries, and Failure Modes
An architecture note on Drupal's render cache: the requirements it must meet, the smallest design that works, trust boundaries around contexts and tags, operational checks, and when to change the design.
07 Sept 2025, 16:39 UTC

Drupal pages are expensive to build: routing, access checks, entity loading, view execution, and theme rendering all run per request. The render cache exists so that work happens once and the resulting HTML is reused — but only when reuse is provably safe. This note walks through the requirements, the smallest design that satisfies them, where the trust boundaries sit, how to check the system in operation, and the conditions that should make you change the design.
Requirements the design must satisfy
Three requirements drive everything else:
- Latency: repeat requests for the same rendered output must not re-execute the render pipeline, for both anonymous and authenticated traffic.
- Correctness under variation: output that legitimately differs — by role, language, route, permissions — must never be served to the wrong variant.
- Freshness: when underlying content changes, dependent cached output must become stale promptly, not eventually.
Drupal's answer is to make cacheability a property of the thing being rendered, declared in the render array itself, rather than a policy bolted on at the edge.
The smallest suitable design
The minimal architecture has three layers, all available in core:
- Render arrays with
#cachemetadata. Every renderable element can declarekeys(identity),contexts(what it varies by),tags(what it depends on), andmax-age. This is the unit of correctness. - Dynamic Page Cache for authenticated and anonymous requests where the full page is cacheable given its contexts.
- BigPipe for progressive delivery: the cached page skeleton streams first, and personalized placeholders stream in afterward.
A typical declaration looks like this in a custom block or controller:
$build['stats'] = [\n '#theme' => 'my_module_stats',\n '#count' => $count,\n '#cache' => [\n 'contexts' => ['user.roles', 'languages'],\n 'tags' => ['node_list:article'],\n 'max-age' => 3600,\n ],\n];Here user.roles means Drupal stores one cached variant per role combination; node_list:article means editing any article invalidates this fragment automatically. If you declare nothing, Drupal's auto-placeholdering may still make the page cacheable by hollowing out the dynamic parts — but explicit metadata is easier to reason about and audit.
Default cache storage is database bins (cache_render, cache_dynamic_page_cache, etc.). For a single-node site this is genuinely sufficient; do not add Redis on day one.
Trust and data boundaries
The critical boundary is between shared cached output and request-specific secrets. Cache contexts are hashed into the cache ID, so two requests with different context values never share an entry. That is the mechanism that keeps one role's HTML away from another role.
The hard rule: markup containing CSRF tokens, session identifiers, or per-request nonces must never enter a shared cache entry. Either set #cache['max-age'] = 0 on that element, or let it be placeholdered so BigPipe renders it per request. A useful audit is to grep custom code for \Drupal::csrfToken() or session reads and confirm each sits inside an uncacheable or placeholdered element.
Cache tags are the other boundary: they connect rendered output to the entities it was built from. If you render a node and forget to merge its cache tags ($renderer->addCacheableDependency() or CacheableMetadata::createFromObject()), edits to that node will not invalidate your fragment. That is the most common stale-content bug in custom Drupal code.
Operational checks
Caching that you cannot observe is caching you cannot trust. Practical checks:
- Header inspection: request a page twice and watch
X-Drupal-Cache(page cache) andX-Drupal-Dynamic-Cachemove from MISS to HIT. A persistent MISS on a page you expect to be cached means a context is varying per request — oftensessionorurl.query_argsleaking in. - Invalidation test: edit a node, then reload a listing that shows it. The listing should come back as a MISS once, then HIT again. If it stays HIT with old content, a tag is missing.
- Bin growth: check the size of
cache_renderover a week. Steady growth without plateau suggests keys that vary per user or per URL where they shouldn't. - Load behavior: a burst tool such as
ab -n 1000 -c 50against a cached page should hold response times flat. Spikes under concurrency can indicate cache stampedes on a hot, frequently invalidated key.
Failure modes
- Stale content from missing cache tags — the dominant failure, and always a code bug, not infrastructure.
- Variant explosion from overly broad contexts (e.g.,
userinstead ofuser.roles), which collapses hit rates and bloats bins. - Cache stampede when a high-traffic key invalidates and hundreds of concurrent requests rebuild it simultaneously.
- Masked regressions: caching can hide slow code paths; profile periodically with the cache cold, using XHProf or Blackfire, so you know what the cache is compensating for.
Conditions that change the design
Move from database bins to Redis or Memcached when cache reads measurably contend with the database, or when you run multiple web nodes and want shared, TTL-evicting storage. If you change the backend in settings.php ($settings['cache']['default'] = 'cache.backend.redis';), clear all caches afterward — stale entries from the old backend will not be visible to the new one, and mixed state produces confusing misses. Consider disabling Dynamic Page Cache only when profiling shows per-user logic dominates most pages; at that point, redesign around placeholders and BigPipe rather than abandoning caching entirely.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.