You configured cache tags, contexts, and max-age carefully back in the Caching Strategies section — but configuration you can't verify is actually working is just a hope. This lesson is about proving it: confirming a specific piece of content really is being served from cache, and diagnosing exactly why it isn't when it should be.
What you'll learn in this lesson
- Reading Drupal's own cache debug headers
- Using Webprofiler's Cache panel for a per-request hit/miss breakdown
- The most common reasons something that should cache doesn't
Drupal's own cache debug headers
// In settings.local.php (never on production):
$settings['cache']['bins']['render'] = 'cache.backend.null';
// ... or, for headers instead of disabling caching entirely:
$config['system.performance']['cache']['page']['use_internal'] = TRUE;
# Check the actual response headers directly:
curl -I https://example.com/some-page | grep -i x-drupal-cache
X-Drupal-Cache: HIT or MISS tells you, per request, whether Drupal's internal page cache actually served that response from cache. X-Drupal-Dynamic-Cache reports the same thing one layer deeper, for the dynamic page cache that still runs for authenticated users (who can't use the full page cache at all).
Webprofiler's Cache panel, revisited
The Cache panel introduced two lessons ago shows exactly which cache bins were hit versus missed on a given request, and — critically — which cache tags and contexts were actually attached to each cached element. If an element you expected to be cacheable shows up with no #cache metadata at all in the profile, that's your answer: something in the render pipeline isn't propagating cache metadata correctly.
The most common reasons something doesn't cache
- An uncacheable element with no placeholder: from the Caching Strategies section — one uncacheable piece anywhere in a render tree poisons the whole tree unless it's wrapped in a
#lazy_builderplaceholder. - Missing
#cachemetadata bubbling: a custom render array that reads data from a service or entity without declaring the corresponding cache tags/contexts — Drupal can't know to invalidate what it was never told to track. - A session started unintentionally: calling anything that touches
\Drupal::currentUser()'s session, or setting a message via the messenger service, starts a PHP session — which immediately disqualifies that response from the page cache entirely, even for an otherwise-anonymous request.
\Drupal::messenger()->addMessage() left in production code, or a form that always initializes CSRF token state even when not submitted, can silently disable page caching site-wide for anonymous users without any obvious error.Quick check:
X-Drupal-Cacheshows MISS on every single request to a genuinely static, anonymous-only page, even after multiple reloads. What's a likely cause worth checking first? If you said something on that page path is unintentionally starting a PHP session, you've identified the single most common real-world cause of "this should obviously be cached but isn't."
Key takeaways
X-Drupal-CacheandX-Drupal-Dynamic-Cacheresponse headers tell you, per request, whether caching actually happened.- Webprofiler's Cache panel shows exactly which cache tags/contexts were attached — a missing one usually means metadata isn't bubbling correctly.
- An unintentional session start (a stray messenger call, uninitialized form state) silently disables page caching for anonymous users — the most common real-world cause of "should cache but doesn't."
- Never leave cache-debugging overrides like
cache.backend.nullenabled outside a local/staging environment.
Coming up next
You've now got the full diagnostic toolkit built entirely from Devel and Drupal's own debug headers. Last in this section: reaching for dedicated external tools like New Relic or Blackfire when you need production-grade, always-on monitoring rather than one-off debugging.