🔍 Cómo investigar una página que Drupal no quiere cachear
En los artículos anteriores vimos dos ideas fundamentales:
- Drupal tiene varias capas de caché.
- La cacheabilidad se describe mediante
tags,contextsymax-age.
Ahora vamos a pasar de la teoría al diagnóstico.
Porque tarde o temprano aparece una página que responde algo como:
X-Drupal-Dynamic-Cache: UNCACHEABLE
o:
Cache-Control: must-revalidate, no-cache, private
y entonces surge la pregunta:
¿Por qué Drupal decidió que esta página no puede cachearse?
La respuesta rara vez está en un único lugar.
Puede venir de:
- una sesión activa;
- una cookie;
- un formulario;
- un bloque dinámico;
- un
max-age: 0; - un cache context demasiado específico;
- un módulo contrib;
- Drupal Commerce;
- código custom;
- una policy de respuesta.
La buena noticia es que Drupal nos da bastantes pistas si sabemos dónde mirar.
🧠 Primero: MISS no significa lo mismo que UNCACHEABLE
Esta diferencia es probablemente la más importante.
Si vemos:
X-Drupal-Dynamic-Cache: MISS
significa algo parecido a:
“Esta respuesta podría cachearse, pero no estaba todavía disponible en caché para esta petición.”
Una segunda petición compatible podría convertirse en:
X-Drupal-Dynamic-Cache: HIT
Eso es normal.
En cambio:
X-Drupal-Dynamic-Cache: UNCACHEABLE
significa:
“Drupal ha determinado que esta respuesta no puede utilizar esta capa de caché.”
Ahí ya no estamos ante una caché vacía.
Estamos ante una decisión de cacheabilidad.
🧪 Empecemos por curl
La herramienta más simple suele ser suficiente para obtener una primera fotografía:
curl -sI https://example.com/
Algunos headers especialmente interesantes son:
cache-control
x-drupal-cache
x-drupal-dynamic-cache
content-language
set-cookie
vary
Por ejemplo:
HTTP/2 200
cache-control: max-age=900, public
x-drupal-dynamic-cache: HIT
nos cuenta una historia bastante diferente de:
HTTP/2 200
cache-control: must-revalidate, no-cache, private
x-drupal-dynamic-cache: UNCACHEABLE
set-cookie: ...
El objetivo inicial no es sacar conclusiones demasiado rápido.
Es recopilar señales.
🔁 Ejecutar una sola petición no basta
Siempre conviene repetir:
curl -sI https://example.com/
curl -sI https://example.com/
curl -sI https://example.com/
¿Por qué?
Porque queremos distinguir:
Primera petición
→ MISS
Siguientes peticiones
→ HIT
de:
Primera petición
→ UNCACHEABLE
Segunda petición
→ UNCACHEABLE
Tercera petición
→ UNCACHEABLE
En el primer caso probablemente estamos viendo comportamiento normal.
En el segundo tenemos que investigar.
🧭 Probar varias rutas
Tampoco debemos asumir que todo el sitio tiene el mismo comportamiento.
Probemos, por ejemplo:
curl -sI https://example.com/
curl -sI https://example.com/products
curl -sI https://example.com/contact
curl -sI https://example.com/cart
Quizá descubrimos algo como:
/
→ HIT
/products
→ HIT
/contact
→ MISS/HIT
/cart
→ UNCACHEABLE
Eso ya nos da una pista muy fuerte.
Probablemente no tenemos un problema global de caché.
Tenemos una ruta específicamente dinámica.
👤 Anonymous vs authenticated
Otra distinción fundamental:
anonymous request
≠
authenticated request
Dynamic Page Cache puede beneficiar a ambos, pero el contexto cambia mucho.
Un usuario autenticado puede introducir:
- sesión;
- permisos;
- roles;
- toolbar;
- mensajes;
- preferencias;
- personalización.
Por eso una página que se comporta perfectamente para anónimos puede tener una estrategia diferente para usuarios autenticados.
Durante diagnóstico debemos preguntarnos siempre:
¿Qué tipo de request estamos analizando?
🍪 Cookies: una pista frecuente
Si vemos:
Set-Cookie
conviene investigarlo.
Las cookies pueden indicar:
- sesión;
- consentimiento;
- carrito;
- tracking;
- preferencias;
- funcionalidades de terceros.
No significa automáticamente que la página sea incorrectamente no cacheable.
Pero puede explicar por qué la respuesta cambia de comportamiento.
Una prueba útil es comparar una petición limpia con una que tenga cookies.
Por ejemplo:
curl -sI https://example.com/
frente a una sesión real desde el navegador.
🛒 Commerce merece atención especial
En Drupal Commerce, una página puede depender de cosas como:
cart
store
currency
price
availability
customer
order
Por eso rutas como:
/cart
son candidatos naturales a tener comportamiento altamente dinámico.
Esto no significa que debamos conseguir:
/cart → HIT
a toda costa.
La pregunta correcta es:
¿La parte que necesita ser dinámica está suficientemente aislada?
Porque quizá:
Header
Menu
Footer
Product teaser
sí pueden reutilizarse, aunque:
Cart contents
no.
Ahí es donde Dynamic Page Cache, placeholders y lazy builders resultan especialmente importantes.
🔎 Revisemos Cache-Control
Otro header fundamental:
Cache-Control
Podemos encontrarnos con algo como:
Cache-Control: public, max-age=900
o:
Cache-Control: private, no-cache, must-revalidate
Esto es especialmente importante si más adelante queremos añadir:
- Varnish;
- reverse proxy;
- CDN;
- edge caching.
Una respuesta marcada como private no está pensada para ser reutilizada libremente por caches compartidas.
Por eso no debemos empezar diciendo:
“Activemos Cloudflare y listo.”
Primero tenemos que entender por qué Drupal está produciendo esa respuesta.
⚙️ Revisar /admin/config/development/performance
Una de las primeras comprobaciones administrativas debe ser:
/admin/config/development/performance
En particular:
Browser and proxy cache maximum age
Si está configurado a:
0
podemos encontrarnos con headers HTTP muy restrictivos.
Eso afecta especialmente al comportamiento frente a browsers y caches externas.
Pero recordemos:
Este valor no es lo mismo que Dynamic Page Cache o Render Cache.
Podemos tener buen caching interno y al mismo tiempo una política HTTP conservadora.
🧩 Ahora necesitamos saber qué metadata tiene la página
Cuando el problema no es evidente, el siguiente paso es inspeccionar:
Cache Tags
Cache Contexts
Max-Age
Durante desarrollo, Drupal puede exponernos headers adicionales como:
X-Drupal-Cache-Tags
X-Drupal-Cache-Contexts
Estos headers son enormemente útiles porque nos muestran qué dependencias está acumulando la respuesta.
Por ejemplo:
X-Drupal-Cache-Contexts:
languages:language_interface
route
user.permissions
url.query_args
Nos permite empezar a razonar:
¿Realmente necesita user.permissions?
¿Depende del query string?
¿Está variando por ruta?
🚩 Buscar contexts con alta cardinalidad
Algunos cache contexts son mucho más costosos que otros.
Por ejemplo:
languages:language_interface
suele tener pocas variantes.
Pero:
user
puede generar una variante por usuario.
Por eso, si una página o componente grande termina variando por:
user
conviene preguntar:
¿Realmente depende del usuario completo o solo de sus permisos o roles?
Una elección demasiado específica puede reducir muchísimo la reutilización de caché.
🔴 Buscar max-age: 0
Una de las causas más importantes de contenido no cacheable es:
max-age: 0
Si un pequeño componente introduce ese valor y la metadata se propaga hacia arriba, podemos terminar con:
Page
└── Block
└── Component
└── max-age: 0
y finalmente:
Page max-age: 0
Aquí aparece una pregunta clave:
¿Ese componente realmente necesita ser no cacheable?
A veces sí.
Pero muchas veces el código está utilizando max-age: 0 como solución rápida a un problema de cache invalidation.
🧯 El anti-pattern clásico
Imaginemos que un bloque muestra contenido incorrecto después de editar un nodo.
El desarrollador añade:
'#cache' => [
'max-age' => 0,
]
El problema desaparece.
Pero no necesariamente estaba resuelto.
Quizá faltaba:
node:42
como cache tag.
Ahora la página siempre muestra información actualizada…
porque ya no se cachea.
Eso puede ser funcionalmente correcto y arquitectónicamente malo.
La pregunta debería haber sido:
¿Por qué Drupal no sabía cuándo invalidar esto?
🧩 Buscar código custom
Cuando una página concreta tiene problemas, revisaría especialmente:
custom blocks
controllers
preprocess hooks
Views plugins
event subscribers
Twig extensions
custom services
Commerce customizations
Y buscaría cosas como:
'#cache' => [
'max-age' => 0,
]
o:
Cache::invalidateTags(...)
o render arrays sin metadata asociada.
También debemos revisar código que carga entidades manualmente y luego construye HTML sin propagar su cacheability.
🛠️ Un ejemplo de código sospechoso
Por ejemplo:
$node = Node::load(42);
return [
'#markup' => $node->label(),
];
Este output depende de:
node:42
pero el render array no está expresando esa dependencia de forma explícita.
Podemos terminar con contenido obsoleto o con desarrolladores desactivando caché para solucionarlo.
Una opción más robusta es propagar la metadata:
$build = [
'#markup' => $node->label(),
];
CacheableMetadata::createFromObject($node)
->applyTo($build);
Así Drupal sabe qué debe invalidar cuando cambie el nodo.
🔬 Webprofiler puede ayudar
Para debugging en desarrollo, Webprofiler puede resultar muy útil.
Nos permite observar cosas como:
- cache hits/misses;
- queries;
- servicios;
- eventos;
- tiempo de render;
- información de request.
No sustituye a entender cacheability metadata, pero puede ayudar a responder:
¿Dónde se está gastando tiempo?
y:
¿Qué ocurre durante este request?
Eso es especialmente útil cuando una página no solamente es UNCACHEABLE, sino además lenta.
🧪 Drupal cacheability debugging
Drupal también permite habilitar herramientas de debugging de render y cacheability.
La idea es poder inspeccionar qué metadata está llegando a los render arrays y a la respuesta final.
Durante desarrollo podemos habilitar configuraciones adicionales en:
sites/development.services.yml
y utilizar servicios de desarrollo para obtener información más detallada.
No deberíamos dejar este tipo de debugging activado indiscriminadamente en producción.
🧠 Una estrategia útil: dividir la página mentalmente
Supongamos que tenemos:
Page
├── Header
├── Navigation
├── Hero
├── Product
├── Recommended products
├── Cart block
└── Footer
Y toda la página aparece como UNCACHEABLE.
No pensemos automáticamente:
“La página no puede cachearse.”
Preguntemos:
¿Qué componente realmente necesita ser dinámico?
Quizá descubrimos:
Cart block
→ dynamic
Everything else
→ cacheable
El problema ya no es:
"How do we cache the page?"
sino:
"How do we isolate the cart?"
Esa reformulación suele llevar a una solución mucho mejor.
💤 Lazy builders
Drupal permite construir componentes mediante:
#lazy_builder
Esto permite reemplazar temporalmente una parte del render tree por un placeholder y resolverla después.
Conceptualmente:
Page
├── cached content
├── cached content
└── placeholder
↓
dynamic content
Así podemos evitar que una pequeña parte dinámica contamine la cacheabilidad de todo el resultado.
🚀 BigPipe
BigPipe lleva esta idea al navegador.
Una página puede enviar rápidamente la estructura cacheable y completar después componentes dinámicos.
Conceptualmente:
Request
↓
Cached page shell
↓
Browser renders
↓
Dynamic placeholders arrive
Esto puede mejorar especialmente la percepción de velocidad para usuarios autenticados.
Pero BigPipe no es magia.
Depende de que hayamos descrito correctamente qué es cacheable y qué no.
🚨 UNCACHEABLE (response policy)
En algunos casos podemos encontrarnos con un header particularmente interesante:
X-Drupal-Dynamic-Cache: UNCACHEABLE (response policy)
Esto nos dice algo más específico que simplemente:
UNCACHEABLE
Dynamic Page Cache aplica response policies para decidir si una respuesta puede almacenarse.
Determinadas características de la respuesta pueden hacer que Drupal la considere no apta para esa caché.
Ejemplos posibles incluyen:
- ciertos tipos de response;
- sesión;
- comportamiento relacionado con cookies;
- rutas o respuestas especiales;
- políticas añadidas por módulos.
En ese caso, nuestra investigación debería cambiar de dirección:
¿Quién está marcando esta response?
en lugar de buscar únicamente:
¿Quién puso max-age: 0?
🧩 El problema puede no estar en el render array
Esta distinción es importante.
Podemos tener:
Render metadata
→ aparentemente cacheable
pero:
Response policy
→ UNCACHEABLE
Por eso debemos mirar tanto:
render cacheability
como:
HTTP response behavior
No son exactamente lo mismo.
🕵️ Event subscribers y response subscribers
Cuando vemos algo extraño a nivel de response, conviene revisar código relacionado con:
KernelEvents::RESPONSE
o subscribers que modifiquen:
- headers;
- cookies;
- cacheability;
- responses.
Un módulo custom podría estar introduciendo:
$response->headers->set(...)
o creando cookies en todas las páginas.
Eso puede tener consecuencias mucho más amplias de lo esperado.
🍪 Cuidado con cookies globales
Supongamos que un módulo añade una cookie en cada request:
Set-Cookie: something=...
Aunque la funcionalidad parezca pequeña, puede afectar cómo Drupal o una capa externa interpreta la respuesta.
Por eso, durante diagnóstico, buscaríamos:
curl -sI https://example.com/ | grep -i cookie
y compararíamos rutas.
Si todas las páginas empiezan a emitir cookies después de activar un módulo, tenemos una pista.
🧭 Una metodología de diagnóstico
En lugar de probar cosas al azar, podemos seguir siempre el mismo orden.
1. Verificar configuración
/admin/config/development/performance
y módulos:
page_cache
dynamic_page_cache
2. Probar una página pública sencilla
curl -sI https://example.com/
varias veces.
3. Comparar rutas
homepage
content page
View
form
cart
4. Comparar usuarios
anonymous
authenticated
5. Revisar headers
Especialmente:
Cache-Control
X-Drupal-Cache
X-Drupal-Dynamic-Cache
Set-Cookie
Vary
6. Activar debugging en desarrollo
Inspeccionar:
Cache Tags
Cache Contexts
7. Buscar max-age: 0
Especialmente en código custom y contrib relacionado.
8. Revisar sesiones y cookies
Preguntar:
¿Quién inicia la sesión?
¿Quién escribe la cookie?
9. Revisar response policies
Especialmente cuando vemos:
UNCACHEABLE (response policy)
10. Aislar el componente dinámico
En lugar de desactivar la caché globalmente.
📋 Podemos convertirlo en un checklist práctico
Cuando encontremos una página problemática:
[ ] ¿Internal Page Cache está activo?
[ ] ¿Dynamic Page Cache está activo?
[ ] ¿Qué valor tiene Browser/proxy max-age?
[ ] ¿Qué devuelve Cache-Control?
[ ] ¿Qué devuelve X-Drupal-Dynamic-Cache?
[ ] ¿MISS cambia a HIT?
[ ] ¿Hay Set-Cookie?
[ ] ¿El usuario tiene sesión?
[ ] ¿Qué cache contexts aparecen?
[ ] ¿Hay context user?
[ ] ¿Existe max-age: 0?
[ ] ¿Algún bloque custom es dinámico?
[ ] ¿Hay formularios?
[ ] ¿Hay Commerce/cart?
[ ] ¿Hay event subscribers?
[ ] ¿La response policy está rechazando la respuesta?
Con esta lista ya podemos resolver muchos casos sin instalar ninguna infraestructura adicional.
📊 Medir también el tiempo
Los headers nos dicen qué está ocurriendo con la caché.
Pero también queremos saber si realmente afecta al rendimiento.
Por ejemplo:
curl -o /dev/null \
-s \
-w 'TTFB: %{time_starttransfer}\nTotal: %{time_total}\n' \
https://example.com/
Podemos comparar:
MISS
TTFB: 0.420s
HIT
TTFB: 0.080s
Eso ya nos muestra el impacto real.
La optimización deja de ser:
“Creo que está más rápido.”
y se convierte en:
“Este cambio redujo el TTFB de 420 ms a 80 ms.”
🧪 Probar antes y después
Para cualquier cambio de rendimiento deberíamos guardar:
Before
After
Por ejemplo:
Before
X-Drupal-Dynamic-Cache: UNCACHEABLE
TTFB: 450 ms
After
X-Drupal-Dynamic-Cache: HIT
TTFB: 95 ms
Eso nos permite saber si realmente arreglamos el problema.
⚠️ No perseguir HIT como objetivo absoluto
Este punto es importante.
No todas las rutas deberían producir:
HIT
Un carrito, un endpoint personalizado o un formulario pueden tener buenas razones para permanecer dinámicos.
La optimización no consiste en conseguir:
HIT everywhere
La meta es:
que todo lo que sea seguro reutilizar se reutilice, y que solo lo realmente dinámico se regenere.
🛒 Un carrito puede ser correcto y UNCACHEABLE
Por ejemplo:
/cart
puede legítimamente depender de:
- sesión;
- usuario;
- order state;
- productos añadidos;
- promociones.
No tendría sentido hacerla públicamente cacheable para todos los visitantes.
Pero sí podemos preguntar:
¿El mini-cart está haciendo no cacheable también la homepage?
Ese es un problema mucho más interesante.
🔬 Diagnóstico antes que infraestructura
Llegados a este punto podemos ver por qué no queríamos empezar nuestra serie con Redis.
Si tenemos:
UNCACHEABLE
y no sabemos por qué, Redis no responde esa pregunta.
Si tenemos:
Cache-Control: private
y queremos utilizar un CDN, primero tenemos que entender quién tomó esa decisión.
Y si tenemos:
max-age: 0
en un bloque custom, Varnish tampoco va a corregirlo.
Primero debemos entender Drupal.
🧠 Un modelo mental útil
Cuando veamos una página problemática podemos pensar:
REQUEST
↓
¿Hay sesión/cookies?
↓
ROUTING / RESPONSE POLICY
↓
¿La response puede cachearse?
↓
RENDER TREE
↓
tags + contexts + max-age
↓
Dynamic Page Cache
↓
HTTP response
En cada nivel existe una razón distinta por la que podemos perder cacheabilidad.
Eso nos ayuda a buscar en el lugar correcto.
✅ La idea principal
Si solo recordamos una cosa de este artículo:
UNCACHEABLEno es el diagnóstico. Es el inicio del diagnóstico.
Nuestro trabajo consiste en descubrir:
¿Por qué?
Puede ser una decisión correcta.
Puede ser un problema de metadata.
Puede ser una sesión.
Puede ser una cookie.
Puede ser una response policy.
Puede ser un bloque custom.
Puede ser Commerce.
La solución dependerá de la causa.
Por eso una buena estrategia de rendimiento empieza con:
🔍 observar, 🧪 comparar, 🧠 entender, 🛠️ corregir, 📊 medir.
Y solo después añadimos nuevas capas de infraestructura.
🔜 En el siguiente artículo
Hasta ahora hemos hablado mucho de páginas públicas y componentes dinámicos.
Pero hay dos mundos especialmente interesantes:
anonymous users
authenticated users
y Drupal los trata de maneras diferentes.
¿Qué pasa cuando hay sesión?
¿Por qué Internal Page Cache está orientado principalmente a usuarios anónimos?
¿Cómo puede Dynamic Page Cache seguir ayudando a usuarios autenticados?
¿Y qué ocurre con formularios, mensajes, toolbar y contenido personalizado?
👉 En el siguiente artículo veremos 👤 “Anonymous vs authenticated: cómo cambia la estrategia de caché en Drupal”.