Back

Loading…

Documentation

🛒 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” .

Contents