🛒 Drupal Commerce y caché: cómo mantener rápido un sitio con carrito, precios y sesión
Drupal Commerce es uno de los mejores ejemplos para entender por qué la caché en Drupal no puede reducirse a:
cacheado
vs
no cacheado
Un sitio de comercio electrónico mezcla constantemente contenido muy reutilizable con información altamente dinámica.
Por ejemplo:
Product page
├── Product title
├── Description
├── Images
├── Related products
├── Price
├── Add to cart form
└── Mini-cart
Muchas de estas partes pueden ser compartidas entre miles de visitantes.
Otras dependen de:
- 🛒 carrito;
- 👤 usuario;
- 🍪 sesión;
- 💰 precio;
- 🎟️ promociones;
- 🏪 store;
- 🌍 moneda o contexto;
- 📦 disponibilidad.
La optimización correcta no consiste en intentar convertir todo en contenido estático.
La pregunta es:
¿Podemos mantener cacheable la mayor parte de la página y aislar solamente aquello que realmente necesita ser dinámico?
Ese es precisamente el tipo de problema que el sistema de caché de Drupal está diseñado para resolver.
🧠 El carrito introduce estado
Imaginemos dos visitantes anónimos.
Visitor A
Cart:
- Product X
- Product Y
y:
Visitor B
Cart:
empty
Para Drupal ambos pueden ser usuarios anónimos.
Pero claramente:
cart(A) ≠ cart(B)
Por tanto, si una página contiene información sobre el carrito, esa pequeña parte ya no es universal.
Esto tiene consecuencias importantes para Internal Page Cache.
La documentación actual de Drupal advierte expresamente que Internal Page Cache asume que las páginas son iguales para todos los usuarios anónimos. Los sitios que personalizan contenido anónimo por sesión —y menciona explícitamente el ejemplo de un shopping cart— deben plantearse desactivar esa capa o realizar la personalización dinámicamente.
⚠️ Pero eso no significa que toda la página sea dinámica
Supongamos nuestra homepage:
Homepage
├── Header
│ └── Mini-cart
├── Hero
├── Featured products
├── Latest projects
└── Footer
Quizá solamente:
Mini-cart
depende de la sesión.
El resto:
Hero
Featured products
Latest projects
Footer
podría ser idéntico para todos.
Sería una mala estrategia concluir:
Mini-cart is dynamic
↓
Entire homepage is uncacheable
La estrategia que queremos es:
Cacheable page
+
Dynamic cart fragment
🛒 El cart block era precisamente un problema de este tipo
Aquí tenemos un ejemplo muy interesante de cómo ha evolucionado Drupal Commerce.
Históricamente, el cart block tenía dependencias relacionadas con:
user
session
cart
Y normalmente ese bloque se coloca en:
header
o:
sidebar
Es decir, aparece prácticamente en todo el sitio.
Esto significa que la metadata del carrito podía influir en todas esas páginas.
El problema fue identificado explícitamente en Drupal Commerce: cada página visitada por un cliente con carrito podía generar una entrada de caché dependiente de ese carrito y esas entradas debían invalidarse cuando el carrito cambiaba.
📈 El problema escala rápidamente
La documentación del cambio ofrece un ejemplo excelente.
Imaginemos:
1 customer
×
10 pages
=
10 cache entries
Cuando ese cliente modifica su carrito:
10 entries
→ invalidated
Ahora:
1,000 customers
×
10 pages
=
10,000 cache entries
Cada carrito genera variaciones e invalidaciones.
Eso significa:
- más entradas de caché;
- más invalidaciones;
- más reconstrucciones;
- menor reutilización;
- peor escalabilidad.
El problema no era que Drupal tuviera poca caché.
Era que el estado del carrito estaba demasiado acoplado a las páginas que lo contenían .
💤 Commerce 3.0.2 cambió esta arquitectura
Desde Drupal Commerce:
3.0.2
el cart block se carga mediante lazy loading.
Esto cambia el modelo conceptual de:
Page + cart state
↓
one cache entry
a algo parecido a:
Page
→ shared cache
Cart fragment
→ cart/user/session-specific
Eso permite que las páginas ya no tengan que invalidarse cada vez que cambia un carrito.
📉 10,000 entradas pueden convertirse en 1,010
El propio change record de Commerce muestra el efecto con un ejemplo.
Antes:
1,000 customers
×
10 pages
=
10,000 page-related cache entries
Con el cart block separado mediante lazy loading:
10 shared pages
+
1,000 cart-specific entries
=
1,010 entries
La diferencia arquitectónica es enorme.
No hemos eliminado el estado dinámico.
Seguimos teniendo:
1,000 carts
porque realmente existen 1,000 estados diferentes.
Lo que hemos eliminado es la multiplicación innecesaria:
cart
×
every page visited
🧠 Esta es una lección que va más allá de Commerce
Este ejemplo resume muy bien una filosofía importante de rendimiento en Drupal:
Cuando algo tiene alta cardinalidad, intentemos evitar que esa cardinalidad se propague a componentes más grandes de lo necesario.
Por ejemplo:
user
session
cart
pueden tener miles de variantes.
Mientras:
homepage
product page
navigation
footer
tienen muchísimas menos.
No queremos combinar ambas cosas innecesariamente.
💤 ¿Qué hace un lazy builder?
Drupal Render API permite definir:
'#lazy_builder'
Un componente no tiene que renderizarse inmediatamente junto con todo su padre.
Conceptualmente:
Page render
│
├── Header
├── Product
├── Footer
└── Placeholder
↓
Cart builder
Drupal puede almacenar primero la estructura cacheable.
Después resuelve el elemento dinámico.
La documentación actual de Render API explica cómo los lazy builders pueden convertirse en placeholders cuando su cacheability cumple determinadas condiciones.
🧩 Placeholdering
Podemos pensar un placeholder como:
"Something dynamic belongs here"
La página cacheable puede contener temporalmente ese marcador.
Después Drupal sustituye:
PLACEHOLDER
por:
Cart: 3 items
para un visitante,
o:
Cart: empty
para otro.
Eso nos da algo mucho más potente que decidir:
entire page cacheable?
yes/no
🚀 Y aquí BigPipe puede entrar en juego
Si el placeholder puede resolverse posteriormente mediante BigPipe, Drupal puede enviar rápidamente:
Header
Hero
Product content
Footer
y completar después:
Cart
Conceptualmente:
Browser
↓
receives cached page
↓
starts rendering immediately
↓
dynamic cart fragment arrives
Eso no solamente mejora el trabajo del servidor.
También puede mejorar la percepción de velocidad del visitante.
🛒 El Add to Cart form sigue una idea parecida
El formulario:
Add to cart
es otro componente interesante.
No es simplemente HTML estático.
Puede depender de:
- producto;
- variation;
- atributos;
- formulario;
- sesión;
- configuración de Commerce.
Drupal Commerce utiliza lazy builders para generar formularios Add to Cart, permitiendo que el producto y otros elementos de la página puedan mantenerse cacheables mientras el formulario se resuelve de manera apropiada.
Otra vez tenemos:
Product
→ highly cacheable
Add-to-cart form
→ dynamically resolved
en lugar de:
Product + form
→ everything dynamic
💰 ¿Y los precios?
El precio merece una atención especial.
A primera vista podríamos pensar:
Product 42
Price: €99
y asumir que es completamente estático.
Pero en Commerce un precio puede potencialmente verse afectado por:
store
currency
customer
promotion
price resolver
quantity
order context
La estrategia correcta depende del proyecto.
Por eso nunca deberíamos solucionar un problema de precios simplemente con:
'#cache' => [
'max-age' => 0,
]
sin investigar primero:
¿De qué contexto depende realmente este precio?
Si el precio es el mismo para todos:
shared cache
puede ser perfectamente válido.
Si varía por contexto:
cache variants
pueden ser suficientes.
Y si realmente depende de estado que no podemos representar correctamente de otra manera:
dynamic rendering
puede ser necesario.
🎟️ Las promociones aumentan la complejidad
Supongamos:
Product
Normal price: €100
pero:
Customer A
Promotion: -20%
Customer B
No promotion
Ahora no tenemos simplemente:
product price
Tenemos:
resolved price
=
product
+
customer/order context
+
promotion rules
La pregunta de cacheability cambia.
Y nuevamente la solución no es:
disable all caching
sino entender exactamente qué factores intervienen.
🏪 Stores y monedas
En proyectos multi-store podemos tener:
Store A
→ EUR
Store B
→ CHF
o incluso distintos precios por store.
La página puede seguir siendo cacheable si Drupal puede expresar esa variación correctamente.
Conceptualmente:
Product page
├── Variant for Store A
└── Variant for Store B
En lugar de:
Product page
→ never cache
Este es el mismo principio que vimos con idiomas y permisos.
🧠 Dynamic no significa no cacheable
Commerce vuelve a demostrarnos esta idea.
Un precio puede ser dinámico.
Un Add to Cart form puede ser dinámico.
Un mini-cart definitivamente es dinámico.
Pero:
Dinámico significa que existen variables que determinan el resultado. No significa automáticamente que el resultado sea imposible de cachear.
La pregunta sigue siendo:
What data?
What context?
What lifetime?
o:
tags
contexts
max-age
🏷️ Cache tags en Commerce
Una página de producto puede depender de entidades como:
commerce_product:42
commerce_product_variation:51
commerce_store:1
Cuando cambia la variation:
variation 51 updated
queremos invalidar los resultados que dependen de ella.
No queremos simplemente esperar:
15 minutes
para que expire.
Este es el mismo modelo de invalidación por datos que vimos anteriormente.
🛍️ El carrito es diferente
El carrito representa estado mutable con mucha frecuencia.
Durante una única sesión podemos hacer:
Add item
↓
Change quantity
↓
Remove item
↓
Apply coupon
↓
Add another item
Si ese estado estuviera acoplado a todas las páginas visitadas:
cart update
↓
invalidate many pages
tendríamos una enorme cantidad de trabajo innecesario.
Separarlo permite:
cart update
↓
invalidate/rebuild cart-related output
sin tocar:
homepage
product page
footer
hero
content
que no cambiaron.
🔎 Esto también cambia cómo diagnosticamos Commerce
Supongamos que tenemos:
https://example.com/products/widget
y:
https://example.com/cart
No deberíamos esperar el mismo comportamiento.
Podemos probar:
curl -sI https://example.com/products/widget
curl -sI https://example.com/cart
La página de producto debería permitir mucha más reutilización.
El carrito puede ser legítimamente dinámico.
⚠️ No utilicemos /cart como benchmark del sitio
Este es un error fácil de cometer.
Si hacemos:
curl -sI https://example.com/cart
y vemos:
UNCACHEABLE
podríamos pensar:
“Commerce está destruyendo nuestra caché.”
Pero quizá:
/cart
es precisamente una ruta donde esperamos alto dinamismo.
Una prueba mucho más interesante sería:
homepage
product listing
product detail
content page
y observar si el cart block está afectando esas páginas.
🔍 La pregunta más importante para un mini-cart
Cuando analizamos un proyecto Commerce moderno deberíamos preguntarnos:
¿El mini-cart está aislado o está introduciendo dependencias de carrito en toda la página?
En Commerce 3.0.2+ Core ya hizo un cambio específicamente orientado a solucionar ese problema con el cart block estándar.
Pero debemos prestar atención si tenemos:
- cart blocks personalizados;
- subclasses del block original;
- themes muy personalizados;
- módulos contrib relacionados con cart;
- implementaciones custom de off-canvas cart;
- bloques que consultan directamente el cart provider.
⚠️ Custom code puede volver a introducir el problema
Supongamos que escribimos un bloque custom:
$carts = $cart_provider->getCarts();
return [
'#markup' => count($carts),
];
y lo colocamos en:
header
Podríamos estar saltándonos la arquitectura lazy que Commerce utiliza actualmente.
Eso significa que una optimización que Core/Commerce ya resolvió puede reaparecer debido a nuestro código custom.
Por eso no basta con actualizar Commerce.
Debemos entender qué hace nuestro código encima.
🔄 Si extendimos el cart block, revisemos Commerce 3.0.2
El change record de Commerce 3.0.2 contiene una advertencia concreta para desarrolladores.
Si habíamos extendido el cart block y sobrescrito:
getCartViews()
esa lógica se movió al servicio:
CartLazyBuilders
con el cambio a lazy loading.
Esto es exactamente el tipo de detalle que debemos revisar durante una actualización mayor de Commerce.
🧪 Un pequeño experimento práctico
Podemos hacer algo muy sencillo.
1. Página limpia
Abrimos:
/product/example
sin carrito.
Medimos:
X-Drupal-Dynamic-Cache
TTFB
2. Añadimos producto al carrito
Después:
Cart: 1 item
Volvemos a visitar:
/product/example
3. Comparamos
Queremos saber:
¿Cambió solamente el cart fragment?
o:
¿Toda la página perdió reutilización?
Este tipo de comparación nos enseña mucho más que simplemente revisar una configuración.
📊 Medir antes y después
Podemos utilizar:
curl -o /dev/null \
-s \
-w 'TTFB: %{time_starttransfer}\nTotal: %{time_total}\n' \
https://example.com/product/example
y repetir varias veces.
Por ejemplo:
Without cart
MISS 320 ms
HIT 75 ms
With cart
HIT 80 ms
sería una señal muy positiva.
Pero algo como:
Without cart
HIT 75 ms
With cart
UNCACHEABLE 420 ms
merecería investigación.
No necesariamente significa que Commerce esté mal.
Significa:
Algo relacionado con el carrito está modificando el comportamiento completo de la página.
🚩 Señales de alerta en un proyecto Commerce
Durante una auditoría, algunas cosas merecen atención especial:
max-age: 0
en bloques Commerce custom.
También:
user
session
cart
propagándose a grandes render arrays.
O consultas al carrito directamente desde:
preprocess_page()
o:
preprocess_html()
porque esas funciones afectan estructuras muy altas del render tree.
⚠️ Cuidado con preprocess_page()
Imaginemos:
function mytheme_preprocess_page(&$variables) {
$variables['cart_count'] = ...;
}
Ahora el carrito está siendo consultado mientras construimos:
page
Si además propagamos incorrectamente —o no propagamos— la cacheability metadata, podemos introducir:
- contenido incorrecto;
- cache variants innecesarias;
- páginas stale;
- o terminar desactivando caché como workaround.
Un componente específico suele ser un lugar arquitectónicamente mucho mejor para esa información.
🎨 Twig tampoco debería resolver estado complejo
Algo parecido ocurre si intentamos hacer demasiadas cosas desde Twig.
Idealmente Twig debería recibir:
cart_count
ya correctamente modelado en un componente cuya cacheability esté definida.
No queremos esconder una dependencia dinámica global dentro de:
page.html.twig
si podemos evitarlo.
🧩 Pensemos en componentes
La arquitectura ideal empieza a verse así:
Page
│
├── Header
│ ├── Logo
│ ├── Navigation
│ └── CartComponent
│
├── ProductComponent
│ ├── ProductInfo
│ ├── Price
│ └── AddToCart
│
└── Footer
Cada componente puede describir su propia cacheability.
Eso es mucho más mantenible que:
page depends on everything
🧠 Commerce hace visible el valor del Render API
En un sitio editorial sencillo podemos olvidar durante un tiempo que Drupal tiene un render tree sofisticado.
Commerce hace muy evidente por qué existe.
Necesitamos ensamblar en una sola página:
static-ish content
+
entity-dependent content
+
permission-dependent content
+
session-dependent content
+
forms
+
cart state
Y aun así queremos rendimiento.
Eso sería muy difícil con una estrategia de caché:
whole HTML string
yes/no
🏗️ Un buen modelo mental para Commerce
Podemos pensar:
Product data
↓
Render Cache
Catalog pages
↓
Dynamic/Page Cache
Cart state
↓
Lazy fragment
Add-to-cart form
↓
Lazy fragment
Browser
↓
BigPipe / placeholders where appropriate
Cada problema vive en una capa diferente.
🌐 ¿Y el CDN?
Aquí debemos ser especialmente cuidadosos.
Una página pública de catálogo puede ser una excelente candidata para:
CDN
si Drupal genera headers adecuados.
Pero:
/cart
o:
/checkout
claramente contienen estado individual.
No queremos enviar esas respuestas a una caché pública compartida.
Por eso una estrategia futura podría parecerse a:
/public catalog pages
→ CDN cache
/cart
/checkout
/account
→ bypass shared cache
Pero no debemos implementar esto todavía basándonos únicamente en rutas.
Primero debemos entender qué headers y metadata está generando Drupal.
🔐 Seguridad antes que HIT ratio
Nunca debemos intentar conseguir:
Cache-Control: public
simplemente porque produce un mejor benchmark.
Una respuesta con:
customer information
cart contents
checkout state
debe mantenerse correctamente aislada.
Un error de caché aquí puede ser mucho más grave que una página lenta.
Por eso:
correctness y privacidad tienen prioridad sobre cache hit ratio
📋 Checklist para Drupal Commerce
Cuando optimicemos un proyecto Commerce podemos empezar con:
[ ] ¿Qué versión de Drupal Commerce usamos?
[ ] ¿Estamos en Commerce 3.0.2+?
[ ] ¿Usamos el cart block estándar?
[ ] ¿Tenemos un cart block custom?
[ ] ¿Extendemos el cart block?
[ ] ¿Consultamos el carrito en preprocess_page?
[ ] ¿El mini-cart está lazy loaded?
[ ] ¿Qué ocurre con Dynamic Page Cache al añadir un producto?
[ ] ¿Las páginas de producto siguen siendo reutilizables?
[ ] ¿Existe algún max-age: 0 custom?
[ ] ¿Los precios varían por usuario/store/context?
[ ] ¿Las promociones introducen contexto adicional?
[ ] ¿El Add to Cart form está correctamente aislado?
[ ] ¿Estamos evaluando /cart separadamente de páginas públicas?
🎯 No persigamos un carrito cacheado
Esta es probablemente la idea más importante del diagnóstico Commerce.
Nuestro objetivo no es:
/cart
→ HIT
Nuestro objetivo es:
cart
→ dynamic where necessary
catalog
→ reusable
product content
→ reusable
navigation
→ reusable
page shell
→ reusable
cart fragment
→ isolated
Eso es mucho más valioso.
🧠 La lección del cart block
El cambio introducido en Commerce 3.0.2 resume perfectamente lo que hemos aprendido durante esta serie.
El problema era:
dynamic cart
↓
cache dependencies bubble
↓
many pages become cart-dependent
La solución fue:
dynamic cart
↓
lazy builder
↓
isolated placeholder
Y así las páginas pueden mantener su propia cacheability independientemente del carrito.
Esta es una excelente demostración de que:
optimizar Drupal muchas veces no consiste en almacenar más cosas en caché, sino en diseñar mejor los límites de lo que cacheamos.
✅ La idea principal
Commerce introduce una enorme cantidad de estado dinámico:
🛒 carrito, 👤 cliente, 💰 precios, 🎟️ promociones, 🏪 stores, 🧾 orders, 📦 variations.
Pero eso no convierte automáticamente todo el sitio en dinámico.
Una buena arquitectura separa:
shared state
de:
customer-specific state
y permite a Drupal reutilizar todo lo demás.
Por eso debemos pensar:
No hagamos no cacheable una página porque contiene algo dinámico. Aislemos aquello que realmente necesita ser dinámico.
Commerce 3.0.2 aplicó exactamente este principio al cart block.
Y esa misma idea podemos utilizar en nuestros propios componentes.
🔜 En el siguiente artículo
Hasta aquí hemos trabajado principalmente dentro de Drupal.
Ya sabemos:
- qué puede cachear;
- cómo describe la cacheability;
- cómo diagnosticar
UNCACHEABLE; - cómo manejar contenido por usuario;
- y cómo aislar elementos dinámicos como el carrito.
Ahora finalmente podemos movernos fuera de Drupal .
¿Qué ocurre cuando ponemos delante:
Varnish
Cloudflare
Fastly
Akamai
another CDN
¿Qué significa realmente:
Cache-Control: public
frente a:
private
¿Cómo se relacionan Drupal Cache Tags con una caché externa?
¿Y por qué instalar un CDN antes de arreglar la cacheability de Drupal puede darnos una falsa sensación de optimización?
👉 En el siguiente artículo veremos 🚦 “De Drupal al CDN: cuándo tiene sentido Varnish, reverse proxy y edge caching” .