Why isn't this Drupal page cached?

Deep dive into the Native Observability project
Drupal writes the answer into the response headers, and the response is gone as soon as it arrives. Native Observability keeps, for every request, what the page cache and the dynamic page cache did, the tags, the contexts and the max-age, and the cache tag invalidations the request caused. It also records requests served by the page cache, which Drupal itself never handles.

How do you find out why a Drupal page was not cached?

You read the reason Drupal itself writes on the response, provided you kept it. The page cache and the dynamic page cache put their verdict in the X-Drupal-Cache and X-Drupal-Dynamic-Cache headers: HIT, MISS, or UNCACHEABLE followed by the rule that decided it in brackets. Those headers reach the browser and stop there: the server keeps no copy of them.

Native Observability is a Drupal module that records from the inside what happens during each request. Its Cache Observer submodule, native_observability_cache_observer, stores in the database the outcome of each cache layer, the cacheability metadata of the response and the cache tag invalidations, tied to the route and to the trace of the same request. Slow queries for the same request are covered in the article on the queries that slow down a route, calls to external services in the one on the external service that slows down a route.

The rows shown here come from a measurement environment I built, with Drupal 11.4.5 on DDEV. How to rebuild it is at the bottom, in the section on repeating the measurement.

What Cache Observer records for each request

Three kinds of event, each stored in the native_observability_cache_event table with the id of the request that produced it.

Event type Layer What it holds Where it comes from
response_cache page_cache HIT, MISS or UNCACHEABLE (reason) the X-Drupal-Cache header, read by a middleware
response_cache dynamic_page_cache the same values the X-Drupal-Dynamic-Cache header, read on kernel.response
cacheability response_cacheability count and list of tags and contexts, max-age the response metadata, getCacheableMetadata()
tag_invalidation cache_tags the tags invalidated by one call an invalidator added next to core's own

The cacheability metadata is read from the response, from the same data core uses to build its debug headers. That means http.response.debug_cacheability_headers can stay false, as the comment in default.services.yml asks: «Enabling cacheability debugging is not recommended in production environments». Every test on this page ran with that parameter off.

A page that is not cached: the three rows that explain why

A GET /node/XXX/edit by a logged-in user leaves three rows with the same request id, XXXXXXXXXXXXXXXXXXXXXXMJ7M, and the same route, entity.node.edit_form:

Layer Recorded outcome
page_cache UNCACHEABLE (REQUEST POLICY)
dynamic_page_cache UNCACHEABLE (POOR CACHEABILITY)
response_cacheability 22 tags, 11 contexts, max-age 0

Each label maps to a specific rule in core.

  • REQUEST POLICY on the page cache: the request carries a session. The page cache only serves

anonymous visitors, and it decides this before it even looks the page up.

  • POOR CACHEABILITY on the dynamic page cache: the response matches one of the

auto_placeholder_conditions in renderer.config. shouldCacheResponse() rejects it when max-age is 0, when a high-cardinality context such as user or session is present, or when a tag is invalidated too often.

  • The cacheability row tells you which of the three fired: max-age 0. The payload column also

holds the names, including the user and session.exists contexts.

Two more outcomes from the same run complete the picture. user.reset.login, the one-time login link, gives UNCACHEABLE (RESPONSE POLICY): the page was built, then a response rule kept it out of the cache. A response that carries no cacheability metadata at all gives UNCACHEABLE (NO CACHEABILITY) on both layers.

The page cache HIT that Drupal never sees

A page served from the page cache gets recorded even though Drupal never handled it. The page cache is the http_middleware.page_cache middleware, at priority 200, and on a HIT it returns the stored response before the HTTP kernel starts: no routing, no kernel.response, no kernel.terminate. As far as the rest of Drupal knows, that request never happened.

Cache Observer sits one step above it, with a middleware at priority 210, CachePageObserverMiddleware, reads X-Drupal-Cache on the response on its way out and writes the row. Since the kernel never assigned an id, the middleware mints one and puts it on the response as X-Native-Observability-Request-Id.

Two anonymous requests to the same page, one after the other:

a1.txt:x-drupal-cache: MISS
a1.txt:x-native-observability-request-id: XXXXXXXXXXXXXXXXXXXXXXP9EJ
a2.txt:x-drupal-cache: HIT
a2.txt:x-native-observability-request-id: XXXXXXXXXXXXXXXXXXXXXXP40A

And in the database, with the number of trace rows each id finds:

id    request_id                  status  path                                 route_name             trace_rows
XXXX  XXXXXXXXXXXXXXXXXXXXXXP9EJ  MISS    /review/what-changed-in-drupal-11-4  entity.node.canonical  1
XXXX  XXXXXXXXXXXXXXXXXXXXXXP40A  HIT     /review/what-changed-in-drupal-11-4                         0

The HIT has zero trace rows and an empty route, and that is the correct answer: Drupal neither routed nor traced that request. For a HIT, what remains is the path. So HITs for a page are counted by path, because route_name only exists on requests that reached the kernel.

Another request's id, inside a cached response

The page cache stores the whole response, headers included. A site that adds a correlation id to every response, to find the request again in its logs, gets that id stored together with the page: the second visitor receives the first visitor's id. Searching the logs for it leads to somebody else's request, possibly from hours earlier.

Cache Observer removes it. The middleware spots a response served from the cache by the missing attribute that the kernel sets on every request it handles, drops the old header and writes a new one. The two requests above show it: different ids, one per request.

A core header has the same problem. On the HIT the response still says x-drupal-dynamic-cache: MISS, which is the dynamic page cache's outcome for the request that filled the page cache. On a HIT the dynamic page cache did no work at all. Anyone reading headers by hand to understand caching on an anonymous page should know this: on a page cache HIT, X-Drupal-Dynamic-Cache describes a different request.

Which invalidation emptied the page

Next to core's invalidators, Cache Observer adds one of its own, ObservedCacheTagsInvalidator, which writes one row per call to invalidateTags() with the tags and the request that invalidated them. Saving a node from its form, that single POST produces these rows:

tag_count  tags
1          node:XXX:revisions
4          node_list, node_list:article, 4xx-response, node:XXX
1          native_observability_trace:list

The second row is the one that explains why the article lists and the node page were rebuilt on the next visit. The third comes from the module itself, and it is covered under the limits.

Invalidations have a per-request cap, max_stored_invalidations_per_request, 50 by default. The cap counts calls, not tags, and keeps the first ones: the invalidation always happens, only the recording stops. The same save with the cap at 1 and at 50:

Cap Rows stored Tags recorded What is left
1 1 1 node:XXX:revisions
50 3 6 all three calls

With the cap at 1, the tags that went missing were exactly node:XXX and node_list, the useful ones.

The real total is not recorded anywhere. For slow queries the module also keeps how many it saw, and comparing rows stored with rows seen proves the truncation. For invalidations that comparison is missing. The only hint is a request with exactly as many rows as the cap allows, and proof comes only from repeating the measurement with a higher cap. It is a limit of Cache Observer, and a counter of the real total is the missing piece.

Limits of Cache Observer

Each one was observed in the same run.

  • A POST leaves only the cacheability row. Core sets X-Drupal-Cache only on requests with a

cacheable method, as RFC 7231 §4.2.3 prescribes, and without the header the middleware writes no page cache row.

  • CACHEABLE means declared cacheability. The label on the cacheability row looks only at tags,

contexts and max-age. A POST to the login form comes out CACHEABLE, although a POST always stays out of the cache. The real outcome is in the response_cache rows.

  • A HIT has the path and an empty route, for the reason given above.
  • Invalidations from drush, and from cron run on the command line, stay out. The invalidator

writes only when the current request has a correlation id, and under drush that request is a synthetic one without it. A drush cr empties everything and leaves the table as it was.

  • The module records its own invalidations. Every request that writes a trace invalidates

native_observability_trace:list, and that call lands in the table like any other: in the run it was 8 invalidation rows out of 12. With a low cap they take slots that belong to the site's tags.

  • The cap on invalidations cuts silently, as described in the previous section.

How it compares with other tools

Each one answers a different question. The table reports what each project states on its own page: the other tools were not tested in the measurement environment.

Tool Which question it answers For production
Core's debug_cacheability_headers which tags, contexts and max-age the response I am looking at has discouraged by core
Log Cache Tags which tags were invalidated, written to dblog no warning, with a switch for the volume
Trace Cache Tags which tags were invalidated, one notice per invalidation «Not recommended for production sites»
Cache review how the page cache and dynamic page cache work, with demo pages a teaching tool, and it says so
WebProfiler what the cache did on the page in front of me no statement
Native Observability what each cache layer did for a past request, and who invalidated what built to stay on

Debug headers and WebProfiler help when the page is in front of you. The invalidation logging modules help when the only question is who emptied the cache. None of the first five records page cache HITs, which are exactly the requests Drupal knows nothing about.

Getting started

  1. Enable the submodule: drush en native_observability_cache_observer -y. It requires

native_observability and runs on Drupal 10 and 11.

  1. Grant access native observability cache observer to whoever needs to read the data, and

administer native observability cache observer to whoever changes the settings or deletes rows.

  1. Check the settings at /admin/config/development/native-observability/settings/cache-observer.

Every capture is on by default, the invalidation cap is 50 and rows are kept for 72 hours.

  1. Verify with two anonymous requests to the same page: the second must answer

X-Drupal-Cache: HIT with an X-Native-Observability-Request-Id different from the first.

The report lives at /admin/reports/native-observability/cache-observer, filterable by layer and status, with a detail view for every event and its payload.

The settings are in the native_observability_cache_observer.settings configuration object:

drush cget native_observability_cache_observer.settings
drush cset native_observability_cache_observer.settings max_stored_invalidations_per_request 50 -y

Repeating the measurement

The two page cache requests, anonymous and without cookies:

URL=https://example.ddev.site/some-published-page
curl -sk -D a1.txt -o /dev/null "$URL"
curl -sk -D a2.txt -o /dev/null "$URL"
grep -iE 'x-drupal-cache:|x-drupal-dynamic-cache:|x-native-observability-request-id' a1.txt a2.txt

The query that ties each page cache event to its trace, when there is one:

SELECT c.id, c.request_id, c.status, c.path, c.route_name,
       (SELECT COUNT(*) FROM native_observability_trace t
         WHERE t.request_id = c.request_id) AS trace_rows
FROM native_observability_cache_event c
WHERE c.cache_layer = 'page_cache'
ORDER BY c.id;

Invalidations stored per request, to compare with the configured cap:

SELECT request_id, method, route_name,
       COUNT(*) AS stored_rows, SUM(tag_count) AS stored_tags
FROM native_observability_cache_event
WHERE event_type = 'tag_invalidation'
GROUP BY request_id, method, route_name
ORDER BY MIN(id);

Saving the node needs a real HTTP request made through the form: a save run from drush would stay out, for the reason given under the limits.

What this test does not prove

  • It is a measurement done by hand, one request at a time, on DDEV. It shows the mechanism, not the

behaviour under a real site's traffic.

  • The measurement environment runs in development mode. For the test I switched off

debug_cacheability_headers and Twig debug by hand, and turned them back on afterwards.

  • The module's tables already held rows from earlier tests and were not emptied. Every count is

filtered on the rows written after the test started.

Sources and references

All sources were checked on 27 September 2026.

  1. Native Observability project page (opens in a new tab). 2.0.x branch, submodules and requirements. Primary source.
  2. Module source, 2.0.x branch (opens in a new tab). CachePageObserverMiddleware for HITs and the request id, CacheResponseObserverSubscriber for cacheability and the dynamic page cache, ObservedCacheTagsInvalidator for invalidations and the cap, config/install for the defaults. Primary source.
  3. Drupal 11 core source: PageCache for the page cache UNCACHEABLE labels and the rule on non-cacheable methods, DynamicPageCacheSubscriber::shouldCacheResponse() for POOR CACHEABILITY, default.services.yml for the warning on debug headers. Primary source.
  4. Improve X-Drupal-Cache and X-Drupal-Dynamic-Cache headers, even for responses that are not cacheable (opens in a new tab), core issue #2951814, open. Read from the rendered DOM.
  5. Cache tags, Drupal guide (opens in a new tab). Read from the rendered DOM.
  6. Log Cache Tags project page (opens in a new tab). Read from the rendered DOM.
  7. Trace Cache Tags project page (opens in a new tab). Read from the rendered DOM.
  8. Cache review project page (opens in a new tab). Read from the rendered DOM.
  9. WebProfiler project page (opens in a new tab). Read from the rendered DOM.
  10. The numbers on this page come from a measurement environment I built, with Drupal 11.4.5 on DDEV and Native Observability 2.0.x. Anyone who wants to check them can repeat them with the commands and queries shown above.
Giorgio Alfredo Pagano
AI modified

This text was translated by AI.

How was AI used?

Translated from the Italian original with AI assistance, then read and corrected by a person, who holds editorial responsibility.