El checkout de Magento sí se puede cachear
Cómo MageObsidian sirve la página de checkout desde el full page cache: un shell público, una sección privada y todo lo que el controlador hacía en cada request.
En casi cualquier tienda Magento, el carrito y el checkout son las dos páginas del embudo que nunca salen del full page cache. La home, las categorías y el detalle de producto se sirven desde Varnish en milisegundos; el checkout se arma en PHP en cada visita, justo cuando el cliente ya decidió comprar.
Lo aceptamos como una ley de la naturaleza. No lo es. El checkout no es incacheable por lo que muestra, sino por cómo Luma lo arma: mezcla en un mismo render datos que son iguales para todos con datos que son de un solo cliente. En MageObsidian separé esas dos mitades y la página ahora puede servirse desde la caché. La separación resultó ser la parte fácil. Lo interesante es todo lo que el controlador hacía en cada request y que ahora tiene que resolver alguien más.
La línea que apaga la caché
El FPC de Magento decide si una página se cachea mirando el layout completo. Basta un bloque con cacheable="false" para que la página entera quede fuera. En el checkout ese bloque es la raíz, en Magento_Checkout/view/frontend/layout/checkout_index_index.xml:
<block class="Magento\Checkout\Block\Onepage" name="checkout.root"
template="Magento_Checkout::onepage.phtml" cacheable="false">
Quitar el atributo no arregla nada, porque el problema está en lo que ese bloque pone en la página. Onepage serializa en window.checkoutConfig todo lo que devuelven los config providers del checkout. Solo el DefaultConfigProvider del core ya mezcla dos mundos:
- Datos de la tienda:
storeCode,priceFormat,postCodes,activeCarriers, las URLs de registro y de éxito. - Datos de este cliente:
formKey,customerData,quoteData,quoteItemData,totalsData,selectedShippingMethod, y hastapaymentMethods, que se calcula a partir del quote.
Si cacheas esa página tal cual, el siguiente visitante recibe el carrito, el email y el form_key del anterior. El cacheable="false" no es pereza del core: es la forma más simple y segura de servir un HTML que lleva datos privados dentro.
La idea ya estaba en Magento
Magento resolvió este problema hace años para el resto de la tienda. El minicart, el nombre en el header y los mensajes de sesión no van en el HTML cacheado: el navegador los pide aparte a /customer/section/load y los guarda en localStorage. Es el mismo mecanismo que usé en el post sobre segmentar el FPC con X-Magento-Vary, donde la regla final era no segmentar lo que es de un solo usuario, sino sacarlo del HTML.
MageObsidian aplica esa regla a la página entera. El CheckoutConfig del módulo se divide en dos métodos:
getPublicConfig(): la tienda, la moneda, las URLs, el modo de layout, si la tienda permite comprar como invitado, los términos y condiciones, la configuración pública de los métodos de pago y la site key de reCAPTCHA. Es igual para todos los clientes de esa tienda y moneda.getPrivateData(): si hay sesión, el carrito enmascarado del invitado, el email, el resumen del quote, la dirección y el método de envío, las direcciones guardadas y los datos de pago del cliente, como las tarjetas guardadas.
Lo que va dentro del HTML depende de una sola decisión:
public function getInlineConfig(): array
{
return $this->gate->isCacheable() ? $this->getPublicConfig() : $this->getConfig();
}
Cuando la página es cacheable, en el HTML solo va la mitad pública. La privada se expone como una sección nueva de customer-data, obsidian-checkout, cuyo SectionSourceInterface devuelve exactamente getPrivateData(). El form_key, que Luma escribe en window.checkoutConfig, no aparece en la mitad pública. Con reCAPTCHA pasa algo parecido: la site key es de la tienda y va en el HTML, pero el token se genera en el navegador y viaja con la petición que coloca la orden.
Como MageObsidian no carga el layout de checkout del core (el árbol de UI components de Knockout nunca llega a la página), no hay un cacheable="false" que quitar. Lo que hay es un plugin sobre Layout::isCacheable() que, solo para checkout_index_index, delega en un Gate: la página es cacheable si la opción está activa en la configuración y la URL no trae ?obsidian_shell=0.
No esperar al JavaScript
La primera versión funcionaba, pero con un costo visible. El HTML llegaba rápido desde la caché y después el navegador tenía que descargar el bundle, montar la isla y solo entonces pedir la sección privada. En el checkout la parte privada no es un adorno como el nombre del header: es el contenido de la página.
La solución fue un bloque SectionPrefetchRuntime en el <head>, que emite un script inline de pocas líneas. Ese script lanza el fetch a /customer/section/load antes de que el navegador empiece a parsear el bundle, y guarda la promesa en una variable global. Cuando la isla se monta, no hace una petición nueva: recoge la que ya estaba en vuelo.
No confiar en la copia local
En el resto de la tienda, una sección de customer-data vive en localStorage hasta que algo la invalida. En Luma eso lo decide sections.xml, y en el fondo la cookie private_content_version, que Magento solo renueva en peticiones POST del área frontend: Magento\Framework\App\PageCache\Version::process() empieza con un if (!$this->request->isPost()) return;.
Para el checkout eso no alcanza. La isla modifica el quote por REST, con carts/mine o guest-carts/:maskedId, y esas peticiones corren en el área webapi_rest, donde nadie mueve la cookie. Por eso el checkout no usa la copia guardada como fuente de verdad: la precarga del <head> pide la sección con force_new_section_timestamp=true y, cuando la página no trae el quote dentro, la isla la recarga en cada montaje, reutilizando la precarga si todavía está en vuelo.
La copia local solo importa cuando esa petición falla, y ahí apareció el bug más interesante. En MageObsidian cambiar de moneda o de tienda es un enlace GET (el selector de Luma usa POST), así que tampoco mueve la cookie. Si un cliente pasa de euros a dólares y la recarga falla, la página en caché llega en dólares mientras la sección guardada sigue en euros.
La corrección fue sellar cada sección con el contexto en que se calculó (código de tienda y moneda) y compararlo con el de la página. Si no coinciden, la isla descarta la sección y la recarga una vez:
if (!belongsToThisPage(section) && !revalidated) {
revalidated = true;
void customerData.reload([PRIVATE_SECTION]);
return;
}
La moneda del sello se lee con getCurrentCurrencyCode(), que consulta primero el contexto HTTP, la misma fuente que usa el FPC para armar su llave. Así el sello y la página se miden con la misma regla.
Lo que el controlador hacía
Esta es la parte que no se ve en un diagrama. El controlador Checkout\Controller\Index\Index del core no se limita a renderizar. Antes de hacerlo:
- manda al carrito si el checkout de una página está desactivado;
- manda al carrito si el quote está vacío, tiene errores o no llega al monto mínimo;
- manda al carrito, con un mensaje, si el cliente no inició sesión y la tienda no permite comprar como invitado;
- inicializa el checkout con
initCheckout().
Además, sobre el controlador cuelgan plugins de otros módulos, como los de multienvío y PayPal del propio core.
En un HIT, nada de eso corre. Con Varnish la respuesta ni siquiera toca PHP; con el FPC integrado PHP arranca, pero el controlador no se ejecuta. Cada una de esas decisiones tiene que tomarse en otro lugar, o asumir que no se toma.
El carrito vacío. Un shell compartido no puede redirigir en el servidor, porque es el mismo HTML para quien tiene tres productos y para quien no tiene ninguno. La isla restaura el comportamiento en cuanto conoce el quote: si está listo y tiene cero artículos, navega a checkout/cart/.
Comprar como invitado. Aquí la redirección se movió antes del checkout. El minicart y la página del carrito construyen el enlace al checkout con la sección cart, que trae isGuestCheckoutAllowed calculado para ese quote, y mandan a iniciar sesión cuando no se permite. La seguridad no cambió de lugar: los plugins VerifyIsGuestCheckoutEnabledBefore* del core siguen rechazando en la API a un invitado que intente guardar el envío o colocar la orden. Se traslada la redirección, no la validación. Lo que no cubre es la visita directa: un invitado que escribe /checkout/ en una tienda sin compra como invitado ve el checkout, y el rechazo le llega al guardar el envío.
Errores y monto mínimo. Estos dos casos no tienen hoy una redirección en el cliente. Un quote con errores o por debajo del mínimo entra al checkout, y el rechazo llega al colocar la orden, porque QuoteValidator y MinimumAmountValidationRule siguen validando en el servidor. Es seguro, pero la experiencia es peor que la del core.
Todo o nada. Un checkout que no conoce el carrito no sirve de nada, y mostrar un esqueleto para siempre es peor que ser lento. Si los datos privados no llegan en ocho segundos, la isla recarga la misma URL con ?obsidian_shell=0. El Gate ve ese parámetro, marca la página como no cacheable y el servidor la renderiza como antes, con los datos privados dentro y con el controlador completo. Esa misma URL sirve para depurar a mano: es la manera de ver la página sin caché sin tocar la configuración.
Pagos en una página compartida
Los métodos de pago son la razón principal por la que esto viene apagado. En Luma, cada método de pago aporta su configuración a través de un config provider, y todos terminan mezclados en window.checkoutConfig.payment. Nada obliga a un provider a devolver solo datos de la tienda.
MageObsidian separa también esto en dos:
- La configuración pública se arma reutilizando los config providers que ya existen, menos cuatro que el módulo excluye por nombre en
di.xml:checkout_default_config_provider,checkout_summary_config_provider,vault_cards_config_providerypersistent_checkout_config_provider. - Los datos privados de cada método, como las tarjetas guardadas en el vault, llegan en la sección por un
MethodDataProviderInterface.
Es una lista de exclusión, no de inclusión, y no es completa: el provider de acuerdos de facturación de PayPal del core, por ejemplo, devuelve datos del cliente y no está en la lista. Lo que limita el daño es la depersonalización de Magento. Antes de renderizar una página cacheable, sus plugins vacían la sesión del cliente, pero la del checkout conserva el quoteId. Un provider que lea al cliente de la sesión no encuentra nada; uno que lea el quote, sí. Por eso el panel de administración lo dice sin rodeos: la opción está apagada por defecto y hay que verificar las extensiones de pago y envío antes de activarla.
Para la interfaz de pago hay un punto de extensión aparte. Los métodos offline del core (cheque, transferencia, contra reembolso, orden de compra y el método gratuito) se completan con una interfaz genérica. Los que en Luma declaran su propio renderer en el layout del checkout necesitan uno para la isla, registrado con PaymentRendererInterface. Si no lo tienen, un flag de configuración, apagado por defecto y sin campo en el admin, los retira del listado y deja registro en el log, en vez de ofrecer un método que el cliente no puede completar.
Cómo se demuestra
Decir que una página es cacheable es fácil; demostrar que lo es y que no filtra datos es otra cosa. El arnés de verificación del proyecto, un conjunto de tests de Playwright contra una tienda real con datos sembrados, tiene un test que agrega un producto, visita el checkout dos veces y exige dos cosas de la segunda respuesta:
const edge = response?.headers()["x-magento-cache-debug"] ?? "";
expect(edge).toBe("HIT");
const served = (await response?.text()) ?? "";
const leaked = PRIVATE_MARKERS.filter((marker) => served.includes(marker.value));
expect(leaked.map((marker) => marker.label)).toEqual([]);
La primera comprueba que la página salió de la caché. La segunda busca en el HTML cuatro marcadores del cliente sembrado (su email, dos calles y su teléfono) y falla si aparece cualquiera. Es una red, no una prueba exhaustiva: no busca, por ejemplo, el id enmascarado del carrito ni los productos del quote. Aun así es el test importante, porque una página en caché que filtra datos es mucho peor que una página lenta.
El mismo patrón en los listados
La idea de separar lo que cambia de lo que no se repite en module-search. Al filtrar, ordenar o paginar una categoría, la página no se vuelve a pedir entera: la misma URL con ?obsidian_fragment=1 ejecuta el controlador nativo completo y responde solo con las regiones que cambian, los filtros y el listado, producidas por los mismos bloques y templates de la página.
Ahí sí hay números. Con Magento 2.4.8 en modo producción y los datos de muestra de Luma, midiendo la mediana de 15 peticiones sin caché en nginx, el fragmento de una categoría responde en 75,7 ms y 117 KB frente a 94,4 ms y 152 KB de la página completa: un 20 % menos de tiempo y un 23 % menos de bytes. Con un filtro aplicado la mejora en tiempo baja al 11 %: lo que se ahorra es armar la página, no consultar los datos.
Límites
- Viene apagado. La opción
Cacheable Checkout Shellestá en Stores → Configuration → MageObsidian → Frontend → Checkout y su valor por defecto es no. La demo pública la tiene apagada. - No tengo números del checkout. En los listados medí; en el checkout todavía no hay un benchmark comparable, y prefiero no inventarlo.
- Una plataforma, una versión. El checkout cacheable está verificado sobre Magento Open Source 2.4.9; el benchmark de listados es de 2.4.8. Adobe Commerce no está verificado.
- Lo que nadie reemplaza todavía. Los errores del quote, el monto mínimo y la regla de invitados en una visita directa solo se validan en el servidor, al guardar el envío o colocar la orden.
initCheckout(), la redirección por checkout desactivado y los plugins del core y de terceros sobre el controlador no corren en un HIT. - Las extensiones de terceros son el riesgo real. Cualquier config provider o renderer que asuma un checkout renderizado por request necesita revisión antes de activar la opción.
Cierre
El checkout de Magento no es especial. Es una página con una parte pública grande y una parte privada que Luma decidió renderizar junta. Separarlas es la parte corta. Lo largo es encontrar cada decisión que el controlador tomaba en silencio en cada request (la redirección del carrito vacío, la regla de invitados, el monto mínimo) y decidir, una por una, quién se hace cargo ahora, o dejar escrito que por ahora nadie.
Si quieres revisar el código, está en mage-obsidian/module-checkout, y la documentación en mage-obsidian.jeanmarcos.dev. Si lo activas en una tienda con extensiones de pago reales, me interesa mucho saber qué se rompe.