🧠 Cacheability en Drupal: cómo funcionan tags, contexts y max-age
En el artículo anterior vimos que Drupal no tiene una única caché y que, antes de pensar en Redis, Varnish o un CDN, debemos entender si Drupal está produciendo contenido correctamente cacheable.
Ahora toca entrar en el corazón de ese sistema.
Drupal describe la cacheabilidad mediante tres conceptos fundamentales:
Cache Tags
Cache Contexts
Cache Max-Age
Podemos pensar en ellos como tres preguntas diferentes:
Cache Tags
→ ¿De qué datos depende este resultado?
Cache Contexts
→ ¿En qué contexto puede cambiar?
Cache Max-Age
→ ¿Durante cuánto tiempo puede reutilizarse?
Estas tres piezas permiten que Drupal haga algo mucho más inteligente que simplemente guardar HTML durante algunos minutos.
Le permiten saber cuándo reutilizar una respuesta, cuándo crear una variante y cuándo invalidarla.
🏷️ Cache Tags: ¿de qué datos depende este contenido?
Imaginemos que renderizamos un nodo:
Node 42
Drupal puede asociar a ese resultado una cache tag como:
node:42
Conceptualmente:
Render result
└── depends on node:42
Mientras el nodo 42 no cambie, Drupal puede reutilizar el resultado.
Pero cuando alguien edita ese nodo:
Node 42 updated
↓
invalidate node:42
↓
cached results depending on node:42
become invalid
Ese es el objetivo principal de los cache tags.
Permiten invalidar la caché cuando cambian los datos de los que depende.
Drupal documenta los cache tags precisamente como el mecanismo para rastrear dependencias de datos e invalidarlas cuando esos datos cambian. (drupal.org)
🧩 Un componente puede depender de muchas cosas
Pensemos en una página de detalle de producto.
Podría depender de:
product:24
taxonomy_term:12
media:55
config:system.site
Si cualquiera de esos elementos cambia, alguna parte de la página podría necesitar regenerarse.
Por eso Drupal puede combinar múltiples tags:
[
"commerce_product:24",
"taxonomy_term:12",
"media:55",
"config:system.site"
]
No tenemos que decidir manualmente:
“Esta página debe expirar dentro de cinco minutos.”
Drupal puede saber de forma mucho más precisa qué cambio hace obsoleto el contenido.
🗂️ Cache tags de listas
Hay otro concepto muy útil.
Supongamos que tenemos una View mostrando artículos:
Latest news
├── Article A
├── Article B
└── Article C
No solamente queremos invalidar el resultado cuando cambia uno de esos artículos.
También queremos invalidarlo si aparece:
Article D
porque ahora la lista debería cambiar.
Drupal utiliza para esto tags de listas, conceptualmente similares a:
node_list
o tags más específicos según el tipo de entidad.
Esto permite representar la diferencia entre:
"este nodo cambió"
y:
"la colección de nodos cambió"
Es una distinción muy poderosa en páginas construidas con Views.
🌍 Cache Contexts: ¿para quién o en qué situación cambia?
Ahora imaginemos otro escenario.
Tenemos el mismo contenido, pero el resultado cambia dependiendo del idioma:
Visitor A
Language: German
→ Produkte
Visitor B
Language: English
→ Products
No queremos invalidar la caché constantemente.
Tampoco queremos declarar:
max-age: 0
Solo necesitamos decirle a Drupal:
Este resultado tiene diferentes variantes según el idioma.
Ahí entran los cache contexts.
Drupal los describe como el equivalente conceptual al header HTTP:
Vary
🧠 Un cache context crea variantes
Si un elemento tiene:
languages:language_interface
podemos imaginar:
Cached component
├── German variant
├── English variant
└── French variant
Drupal no está diciendo:
“Esto no se puede cachear.”
Está diciendo:
“Esto se puede cachear, pero necesito distinguir varias versiones.”
Esa diferencia es fundamental.
👤 Contextos de usuario
Otro ejemplo típico:
user.permissions
Un bloque puede mostrarse de forma diferente dependiendo de los permisos del usuario.
Drupal podría mantener distintas variantes según esos permisos.
Lo importante es no saltar directamente a:
max-age: 0
simplemente porque algo es personalizado.
Muchas personalizaciones pueden expresarse correctamente mediante cache contexts.
🔎 Algunos contextos habituales
Drupal incluye contextos relacionados con cosas como:
route
url
url.query_args
languages
user
user.permissions
user.roles
theme
timezone
Cada uno representa una dimensión potencial de variación.
Por ejemplo:
url.query_args:page
puede significar:
/news?page=0
/news?page=1
/news?page=2
Son respuestas diferentes, pero cada una puede seguir siendo cacheable.
⚠️ Más contexts no siempre significa mejor
Aquí aparece otro aspecto importante.
Supongamos que un bloque declara:
user
Eso potencialmente genera una variante por usuario.
Con:
10 users
puede no importar demasiado.
Pero con:
100,000 users
puede generar una enorme cantidad de variantes.
Quizá el componente realmente solo dependía de:
user.roles
o:
user.permissions
En ese caso utilizar user sería demasiado específico.
Podemos pensar en esto como cardinalidad de caché:
language
→ pocas variantes
role
→ algunas variantes
permissions
→ más variantes
user
→ potencialmente miles o millones
Por eso elegir correctamente los cache contexts también es una decisión de rendimiento.
⏱️ Cache Max-Age: ¿cuánto tiempo puede reutilizarse?
El tercer elemento es:
max-age
Drupal lo utiliza para describir durante cuánto tiempo un resultado puede considerarse válido.
Por ejemplo:
'#cache' => [
'max-age' => 3600,
]
significa conceptualmente:
cacheable for one hour
Drupal utiliza segundos para representar este valor. (drupal.org)
♾️ Permanent
Un elemento puede ser válido hasta que alguna de sus dependencias sea invalidada.
En Drupal esto se representa normalmente mediante:
Cache::PERMANENT
No significa:
“Guardar para siempre aunque cambie todo.”
Significa más bien:
“No existe una expiración basada en tiempo; utiliza invalidación.”
Por ejemplo:
Node render
max-age = permanent
tag = node:42
puede permanecer cacheado indefinidamente…
hasta que:
node:42
sea invalidado.
Esta combinación es mucho más precisa que una expiración arbitraria de cinco minutos.
🔴 max-age: 0
El valor que más atención merece es:
max-age: 0
Significa que el resultado no es cacheable.
Esto puede ser legítimo.
Por ejemplo, quizá un componente realmente necesita mostrar información completamente dinámica en cada request.
Pero debemos utilizarlo con cuidado.
Porque Drupal combina la metadata de cacheabilidad.
Y ahí empieza uno de los problemas más comunes.
🫧 La metadata de caché “sube”
Imaginemos esta estructura:
Page
└── Main content
└── Product
└── Price block
Cada nivel tiene metadata.
Por ejemplo:
Page
max-age: permanent
Product
max-age: permanent
Price block
max-age: 0
Cuando Drupal combina esta información, el elemento superior debe respetar la restricción más fuerte.
Podemos pensar:
permanent
+
permanent
+
0
=
0
Es decir:
un pequeño componente no cacheable puede hacer que un resultado mucho mayor deje de ser cacheable.
Este comportamiento se conoce habitualmente como cacheability bubbling.
La documentación del Render API explica precisamente que los cache tags, contexts y max-age se propagan desde los elementos hijos hacia sus padres. (drupal.org)
🧩 Un ejemplo sencillo con Render API
Un render array puede declarar:
$build = [
'#markup' => 'Hello',
'#cache' => [
'tags' => [
'node:42',
],
'contexts' => [
'languages:language_interface',
],
'max-age' => 3600,
],
];
Aquí estamos diciendo:
Data dependency
→ node:42
Variation
→ interface language
Time
→ 3600 seconds
Es una descripción bastante completa del comportamiento de caché del componente.
🔗 Cache metadata debe acompañar a los datos
Este principio es extremadamente importante cuando desarrollamos código custom.
Imaginemos que cargamos un nodo:
$node = Node::load(42);
y generamos manualmente:
$build['title'] = [
'#markup' => $node->label(),
];
Nuestro HTML depende del nodo 42.
Por tanto, esa dependencia debería aparecer también en la metadata de caché.
Si no lo hacemos, podemos crear un problema muy peligroso:
Node changes
↓
Drupal doesn't know cached output depends on it
↓
stale content
Por eso Drupal trabaja mucho con interfaces como:
CacheableDependencyInterface
y helpers que permiten propagar automáticamente la metadata de objetos cacheables.
🛠️ CacheableMetadata
Una herramienta muy útil en código custom es:
use Drupal\Core\Cache\CacheableMetadata;
Por ejemplo:
$cacheability = CacheableMetadata::createFromObject($node);
$cacheability->applyTo($build);
Ahora nuestro render array hereda correctamente la cacheability metadata de la entidad.
La idea es sencilla:
Si nuestro output depende de algo, su metadata de caché también debe depender de ese algo.
Drupal documenta CacheableMetadata precisamente como una forma de combinar y aplicar cacheability metadata. (api.drupal.org)
🧠 Los tres conceptos trabajan juntos
Supongamos un bloque:
"Welcome, Daniel"
Podría tener:
tags
→ user:42
contexts
→ user
max-age
→ permanent
Esto significa:
El contenido depende de user 42
Existe una variante según el usuario
Puede permanecer cacheado
hasta que cambien sus dependencias
Eso es muy diferente de:
max-age: 0
Ambos pueden producir contenido personalizado.
Pero el primero puede aprovechar la caché.
🎯 Invalidar no es lo mismo que expirar
Esta diferencia merece una sección propia.
Expiración
max-age: 600
significa:
Después de 600 segundos deja de considerarse válido.
Invalidación
node:42
significa:
Sigue siendo válido hasta que cambie el nodo 42.
Drupal favorece mucho este segundo modelo.
Por ejemplo:
Article doesn't change for 3 days
Con:
max-age: 300
podríamos reconstruirlo cientos de veces innecesariamente.
Con:
max-age: permanent
tags: node:42
podemos reutilizarlo durante los tres días completos y regenerarlo exactamente cuando cambie.
Eso es mucho más eficiente.
🌐 ¿Cómo se relaciona esto con HTTP?
Aquí debemos mantener separados dos niveles.
Drupal puede tener internamente:
tags
contexts
max-age
para gestionar su render cache.
Después la respuesta final genera headers HTTP como:
Cache-Control
Un browser o CDN no entiende:
node:42
como cache tag de Drupal.
Pero Drupal sí utiliza toda esa metadata para decidir qué tipo de respuesta puede construir finalmente.
Por eso:
Drupal cacheability
↓
response cacheability
↓
HTTP headers
están relacionados, pero no son exactamente lo mismo.
🪆 Cacheability en componentes anidados
Podemos visualizar una página Drupal así:
Page
│
├── Header
│ ├── Logo
│ └── Main navigation
│
├── Content
│ ├── Node
│ ├── Media
│ └── Related View
│
└── Footer
Cada componente puede aportar:
tags
contexts
max-age
Drupal combina todo esto.
Por ejemplo:
Main navigation
tags:
config:system.menu.main
Node
tags:
node:42
Media
tags:
media:8
Related View
tags:
node_list
La página puede terminar acumulando:
config:system.menu.main
node:42
media:8
node_list
Así, si cambia cualquiera de esas dependencias, Drupal sabe qué debe invalidar.
⚠️ El error clásico: desactivar caché para solucionar un problema
Durante desarrollo aparece a veces una solución rápida:
'#cache' => [
'max-age' => 0,
]
y aparentemente:
“Ahora sí se actualiza correctamente.”
Pero quizá el problema real era que faltaba:
cache tag
o:
cache context
Por ejemplo, si algo cambia según idioma y no declaramos:
languages:language_interface
podemos ver contenido incorrecto.
Poner:
max-age: 0
puede ocultar el bug.
Pero a cambio hemos eliminado la caché.
Una regla útil durante desarrollo es:
Cuando sentimos que necesitamos
max-age: 0, primero preguntémonos si lo que realmente falta es una tag o un context.
🔍 Cache contexts también pueden causar problemas
El problema contrario también existe.
Supongamos que añadimos:
user
a toda una página únicamente porque un pequeño icono cambia para cada usuario.
Ahora podemos generar una variante completa de página para cada usuario.
Tal vez habría sido mejor aislar ese pequeño componente dinámico mediante:
lazy builder
o alguna otra estrategia dinámica.
Aquí vemos nuevamente que optimizar Drupal no consiste simplemente en:
"añadir caché"
sino en:
describir correctamente la cacheability
💤 Lazy builders y contenido dinámico
Drupal puede retrasar el renderizado de determinadas partes mediante:
#lazy_builder
y placeholders.
Esto permite situaciones conceptuales como:
Page
├── 95% cached
└── Cart block dynamically generated
en lugar de:
Entire page
max-age: 0
Esta arquitectura es fundamental para funcionalidades como BigPipe y para páginas con pequeñas zonas personalizadas.
Entraremos en esto con más detalle cuando hablemos de usuarios autenticados y Drupal Commerce.
🛒 Un adelanto de Drupal Commerce
Imaginemos una página de producto:
Product
├── Name
├── Description
├── Gallery
├── Price
└── Add to cart
Quizá:
Name
Description
Gallery
son altamente cacheables.
Pero:
Price
Cart state
Availability
pueden depender de contexto adicional.
La solución ideal normalmente no es:
entire page = uncacheable
sino describir correctamente esas dependencias y aislar las partes realmente dinámicas.
Ahí es donde Dynamic Page Cache empieza a mostrar todo su valor.
🧪 ¿Cómo podemos inspeccionar esta metadata?
Durante desarrollo Drupal puede exponernos información adicional.
En services.yml podemos habilitar determinados headers de debugging.
Entonces podemos observar cosas como:
X-Drupal-Cache-Tags
X-Drupal-Cache-Contexts
Esto nos permite ver directamente que una respuesta depende, por ejemplo, de:
languages:language_interface
route
user.permissions
o de tags como:
node:42
config:system.site
Drupal documenta estos mecanismos precisamente para depurar cacheability. (drupal.org)
🧠 Una forma práctica de diagnosticar
Cuando encontramos una página que parece no cachearse correctamente, podemos hacernos tres preguntas.
1. 🏷️ ¿Qué datos utiliza?
Por cada dato importante:
node?
term?
user?
config?
commerce product?
preguntamos:
¿Existe una cache tag correspondiente?
2. 🌍 ¿Cuándo cambia el resultado?
Por ejemplo:
language?
route?
query parameter?
role?
permissions?
user?
session?
preguntamos:
¿Tenemos el cache context apropiado?
3. ⏱️ ¿Realmente depende del tiempo?
Solo entonces preguntamos:
¿Necesita un max-age específico?
Muchas veces la respuesta será:
No.
It can remain cached until invalidated.
📐 Podemos pensar en cacheability como una función
Una manera bastante útil de visualizar el sistema es:
Cached result =
Data
×
Context
×
Time
Donde:
Data
→ cache tags
Context
→ cache contexts
Time
→ max-age
Si describimos correctamente esas tres dimensiones, Drupal puede reutilizar el resultado de manera segura.
🔁 ¿Qué pasa cuando Drupal combina dos componentes?
Supongamos:
Component A
tags:
node:42
contexts:
language
max-age:
permanent
y:
Component B
tags:
user:7
contexts:
user.permissions
max-age:
300
El contenedor debe representar las dependencias de ambos.
Conceptualmente:
tags:
node:42
user:7
contexts:
language
user.permissions
max-age:
300
Para max-age, la restricción más corta gana.
Y si uno tiene:
max-age: 0
entonces:
combined max-age = 0
Ese detalle explica por qué un pequeño componente puede afectar tanto al comportamiento de una página.
🧩 Bubbleable Metadata
En Render API podemos encontrarnos con el término:
Bubbleable Metadata
incluye información como:
cacheability metadata
attachments
La palabra “bubbleable” describe precisamente la idea:
child
↑
metadata bubbles
↑
parent
↑
page
Entender este mecanismo ayuda muchísimo cuando desarrollamos:
- custom blocks;
- preprocess functions;
- controllers;
- custom render arrays;
- Views plugins;
- Commerce components.
✅ Buenas prácticas para código custom
Podemos resumir varias reglas.
🏷️ Si utilizamos una entidad
Propagar su cacheability metadata.
🌍 Si el output cambia según algo del request
Añadir el cache context correspondiente.
⏱️ No utilizar max-age: 0 por defecto
Antes debemos demostrar que el componente realmente no puede cachearse.
🧩 Mantener la metadata junto con el render array
No separar la lógica del contenido de su comportamiento de caché.
💤 Aislar pequeñas partes dinámicas
En lugar de hacer no cacheable todo el contenedor.
🚩 Algunas señales de alerta
Cuando revisemos código Drupal, estas líneas deberían hacernos mirar dos veces:
'#cache' => [
'max-age' => 0,
]
También:
Cache::invalidateTags(...)
ejecutado indiscriminadamente.
O:
cache context: user
en componentes muy grandes.
No significa necesariamente que estén mal.
Significa:
Aquí hay una decisión importante de cacheability que debemos entender.
🧪 Un pequeño ejemplo completo
Imaginemos un bloque custom que muestra el título del nodo actual.
Podríamos construirlo así conceptualmente:
$build = [
'#markup' => $node->label(),
'#cache' => [
'tags' => $node->getCacheTags(),
'contexts' => [
'route',
],
'max-age' => $node->getCacheMaxAge(),
],
];
Ahora tenemos:
route
→ el nodo actual puede cambiar según la ruta
node tags
→ si cambia el nodo, invalidar
max-age
→ heredar comportamiento temporal
Pero Drupal nos ofrece mecanismos para propagar esa metadata sin reconstruir todo manualmente.
La idea fundamental permanece:
El HTML y su cacheability metadata son dos partes de la misma respuesta.
🏗️ Volvamos a nuestra arquitectura
En el artículo anterior vimos algo parecido a:
Browser
↓
CDN
↓
Reverse Proxy
↓
Drupal Page Cache
↓
Dynamic/Render Cache
↓
Cache Backend
Ahora podemos añadir otra capa conceptual:
Drupal Render System
↓
Cache Tags
Cache Contexts
Cache Max-Age
↓
Dynamic / Render Cache
↓
Page response
Estos tres valores son parte de la información que permite a las demás capas tomar decisiones correctas.
✅ La idea principal
Si queremos recordar una sola cosa:
Cacheability no significa decidir si algo está cacheado o no. Significa describir correctamente cuándo un resultado puede reutilizarse.
Drupal necesita conocer tres cosas:
🏷️ Tags: de qué datos depende. 🌍 Contexts: en qué situaciones cambia. ⏱️ Max-age: durante cuánto tiempo es válido.
Cuando esa metadata es correcta, Drupal puede hacer cosas muy potentes:
- reutilizar contenido;
- invalidarlo exactamente cuando cambia;
- mantener diferentes variantes;
- combinar componentes;
- renderizar dinámicamente solo lo necesario.
Y cuando esa metadata es incorrecta, aparecen dos clases de problemas:
Too much caching
→ stale / incorrect content
Too little caching
→ unnecessary work / poor performance
El objetivo está justo en medio:
Cachear todo lo que sea seguro cachear y regenerar solamente lo que realmente cambió.
🔜 En el siguiente artículo
Ya sabemos cómo Drupal describe la cacheabilidad.
Ahora podemos utilizar ese conocimiento para investigar problemas reales.
¿Qué significa exactamente:
X-Drupal-Dynamic-Cache: HIT
frente a:
MISS
o:
UNCACHEABLE
¿Cómo podemos descubrir qué bloque está introduciendo un max-age: 0?
¿Qué podemos revisar con curl, DevTools, Webprofiler y los headers de debug?
👉 En el siguiente artículo pasaremos de la teoría al diagnóstico con 🔍 “Cómo investigar una página que Drupal no quiere cachear”.