Magento's checkout can be cached after all
How MageObsidian serves the checkout page from the full page cache: a public shell, a private section, and everything the controller used to do on every request.
In almost any Magento store, the cart and the checkout are the two pages of the funnel that never come out of the full page cache. The home page, categories and product pages are served from Varnish in milliseconds; the checkout is assembled in PHP on every visit, right when the shopper has already decided to buy.
We accept it as a law of nature. It isn’t one. The checkout is not uncacheable because of what it shows, but because of how Luma builds it: a single render mixes data that is the same for everyone with data that belongs to one shopper. In MageObsidian I split those two halves, and the page can now be served from the cache. The split turned out to be the easy part. The interesting part is everything the controller used to do on every request that someone else now has to handle.
The line that turns off the cache
Magento’s FPC decides whether a page is cacheable by looking at the whole layout. One block with cacheable="false" is enough to leave the entire page out. In the checkout that block is the root, in 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">
Removing the attribute fixes nothing, because the problem is what that block puts on the page. Onepage serializes into window.checkoutConfig everything the checkout config providers return. Core’s DefaultConfigProvider alone already mixes two worlds:
- Store data:
storeCode,priceFormat,postCodes,activeCarriers, the registration and success URLs. - This shopper’s data:
formKey,customerData,quoteData,quoteItemData,totalsData,selectedShippingMethod, and evenpaymentMethods, which is computed from the quote.
Cache that page as it is and the next visitor gets the previous one’s cart, email and form_key. The cacheable="false" is not laziness on core’s part: it is the simplest safe way to serve HTML that carries private data inside.
The idea was already in Magento
Magento solved this problem years ago for the rest of the store. The minicart, the name in the header and the session messages are not in the cached HTML: the browser requests them separately from /customer/section/load and keeps them in localStorage. It is the same mechanism from my post on segmenting the FPC with X-Magento-Vary, where the final rule was not to segment what belongs to a single user, but to take it out of the HTML.
MageObsidian applies that rule to the whole page. The module’s CheckoutConfig is split into two methods:
getPublicConfig(): the store, the currency, the URLs, the layout mode, whether the store allows guest checkout, the terms and conditions, the public configuration of the payment methods and the reCAPTCHA site key. It is the same for every shopper of that store and currency.getPrivateData(): whether there is a session, the guest’s masked cart id, the email, the quote summary, the shipping address and method, the saved addresses and the shopper’s payment data, such as saved cards.
What goes into the HTML depends on a single decision:
public function getInlineConfig(): array
{
return $this->gate->isCacheable() ? $this->getPublicConfig() : $this->getConfig();
}
When the page is cacheable, only the public half goes into the HTML. The private half is exposed as a new customer-data section, obsidian-checkout, whose SectionSourceInterface returns exactly getPrivateData(). The form_key, which Luma writes into window.checkoutConfig, is not in the public half. reCAPTCHA works in a similar way: the site key belongs to the store and goes into the HTML, but the token is generated in the browser and travels with the request that places the order.
Since MageObsidian does not load core’s checkout layout (the Knockout UI component tree never reaches the page), there is no cacheable="false" to remove. What there is instead is a plugin on Layout::isCacheable() that, only for checkout_index_index, delegates to a Gate: the page is cacheable if the option is enabled in configuration and the URL does not carry ?obsidian_shell=0.
Not waiting for the JavaScript
The first version worked, but at a visible cost. The HTML arrived quickly from the cache, and then the browser had to download the bundle, mount the island and only then request the private section. In the checkout, the private part is not decoration like the name in the header: it is the content of the page.
The fix was a SectionPrefetchRuntime block in the <head> that emits a few lines of inline script. That script fires the fetch to /customer/section/load before the browser starts parsing the bundle, and stores the promise in a global variable. When the island mounts, it does not make a new request: it picks up the one already in flight.
Not trusting the local copy
In the rest of the store, a customer-data section lives in localStorage until something invalidates it. In Luma that is decided by sections.xml, and underneath by the private_content_version cookie, which Magento only renews on POST requests in the frontend area: Magento\Framework\App\PageCache\Version::process() starts with if (!$this->request->isPost()) return;.
For the checkout that is not enough. The island changes the quote over REST, through carts/mine or guest-carts/:maskedId, and those requests run in the webapi_rest area, where nothing moves the cookie. So the checkout does not treat the stored copy as the source of truth: the <head> prefetch requests the section with force_new_section_timestamp=true and, when the page does not carry the quote inline, the island reloads it on every mount, reusing the prefetch if it is still in flight.
The local copy only matters when that request fails, and that is where the most interesting bug showed up. In MageObsidian, switching currency or store is a GET link (Luma’s switcher uses POST), so it does not move the cookie either. If a shopper switches from euros to dollars and the reload fails, the cached page arrives in dollars while the stored section is still in euros.
The fix was to stamp each section with the context it was computed in (store code and currency) and compare it with the page’s. If they do not match, the island discards the section and reloads it once:
if (!belongsToThisPage(section) && !revalidated) {
revalidated = true;
void customerData.reload([PRIVATE_SECTION]);
return;
}
The stamp’s currency is read with getCurrentCurrencyCode(), which looks at the HTTP context first, the same source the FPC uses to build its key. That way the stamp and the page are measured by the same rule.
What the controller used to do
This is the part a diagram does not show. Core’s Checkout\Controller\Index\Index controller does more than render. Before rendering, it:
- sends the shopper to the cart if one-page checkout is disabled;
- sends the shopper to the cart if the quote is empty, has errors or is below the minimum amount;
- sends the shopper to the cart, with a message, if they are not logged in and the store does not allow guest checkout;
- initializes the checkout with
initCheckout().
On top of that, other modules hang plugins on the controller, such as core’s own multishipping and PayPal plugins.
On a HIT, none of that runs. With Varnish the response does not even touch PHP; with the built-in FPC PHP boots, but the controller does not execute. Each of those decisions has to be made somewhere else, or accepted as not being made.
The empty cart. A shared shell cannot redirect on the server, because it is the same HTML for someone with three products and for someone with none. The island restores the behaviour as soon as it knows the quote: if it is ready and has zero items, it navigates to checkout/cart/.
Guest checkout. Here the redirect moved to before the checkout. The minicart and the cart page build the link to the checkout from the cart section, which carries isGuestCheckoutAllowed computed for that quote, and send the shopper to sign in when it is not allowed. Security did not move: core’s VerifyIsGuestCheckoutEnabledBefore* plugins still reject, at the API, a guest who tries to save shipping or place the order. The redirect moves; the validation does not. What this does not cover is the direct visit: a guest who types /checkout/ in a store without guest checkout sees the checkout, and the rejection comes when they save shipping.
Errors and minimum amount. These two cases have no client-side redirect today. A quote with errors or below the minimum gets into the checkout, and the rejection comes when the order is placed, because QuoteValidator and MinimumAmountValidationRule still validate on the server. It is safe, but the experience is worse than core’s.
All or nothing. A checkout that does not know the cart is useless, and showing a skeleton forever is worse than being slow. If the private data has not arrived after eight seconds, the island reloads the same URL with ?obsidian_shell=0. The Gate sees that parameter, marks the page as uncacheable, and the server renders it as before, with the private data inside and the full controller. The same URL is handy for debugging: it is how you see the page uncached without touching configuration.
Payments on a shared page
Payment methods are the main reason this ships turned off. In Luma, each payment method contributes its configuration through a config provider, and they all end up mixed in window.checkoutConfig.payment. Nothing forces a provider to return only store data.
MageObsidian splits this in two as well:
- The public configuration is built by reusing the existing config providers, minus four that the module excludes by name in
di.xml:checkout_default_config_provider,checkout_summary_config_provider,vault_cards_config_providerandpersistent_checkout_config_provider. - Each method’s private data, such as the cards saved in the vault, arrives in the section through a
MethodDataProviderInterface.
It is a denylist, not an allowlist, and it is not complete: core’s PayPal billing agreement provider, for example, returns shopper data and is not on the list. What limits the damage is Magento’s depersonalization. Before rendering a cacheable page, its plugins clear the customer session, but the checkout one keeps the quoteId. A provider that reads the customer from the session finds nothing; one that reads the quote does. That is why the admin panel says it plainly: the option is off by default, and payment and shipping extensions need to be verified before turning it on.
The payment interface has its own extension point. Core’s offline methods (check, bank transfer, cash on delivery, purchase order and the free method) are completed with a generic interface. Methods that declare their own renderer in Luma’s checkout layout need one for the island, registered through PaymentRendererInterface. If they have none, a configuration flag, off by default and without an admin field, withdraws them from the list and logs it, instead of offering a method the shopper cannot complete.
How it is proven
Saying a page is cacheable is easy; proving that it is and that it does not leak data is something else. The project’s verification harness, a set of Playwright tests against a real store with seeded data, has a test that adds a product, visits the checkout twice and demands two things of the second response:
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([]);
The first checks that the page came out of the cache. The second searches the HTML for four markers of the seeded shopper (their email, two streets and their phone number) and fails if any of them appears. It is a net, not an exhaustive proof: it does not look for, say, the masked cart id or the quote’s products. It is still the important test, because a cached page that leaks data is much worse than a slow page.
The same pattern in listings
The idea of separating what changes from what does not repeats in module-search. When a category is filtered, sorted or paged, the page is not requested in full again: the same URL with ?obsidian_fragment=1 runs the full native controller and responds with only the regions that change, the filters and the listing, produced by the page’s own blocks and templates.
Here there are numbers. On Magento 2.4.8 in production mode with Luma’s sample data, taking the median of 15 uncached requests at nginx, a category fragment responds in 75.7 ms and 117 KB against 94.4 ms and 152 KB for the full page: 20% less time and 23% fewer bytes. With one filter applied the time improvement drops to 11%: what is saved is assembling the page, not querying the data.
Limits
- It is off by default. The
Cacheable Checkout Shelloption lives in Stores → Configuration → MageObsidian → Frontend → Checkout and defaults to no. The public demo has it off. - I have no checkout numbers. I measured the listings; for the checkout there is no comparable benchmark yet, and I would rather not make one up.
- One platform, one version. The cacheable checkout is verified on Magento Open Source 2.4.9; the listing benchmark is from 2.4.8. Adobe Commerce is not verified.
- What nothing replaces yet. Quote errors, the minimum amount and the guest rule on a direct visit are only validated on the server, when shipping is saved or the order is placed.
initCheckout(), the disabled-checkout redirect and the core and third-party plugins on the controller do not run on a HIT. - Third-party extensions are the real risk. Any config provider or renderer that assumes a checkout rendered per request needs a review before turning the option on.
Closing
Magento’s checkout is not special. It is a page with a large public part and a private part that Luma chose to render together. Splitting them is the short part. The long part is finding every decision the controller quietly made on every request (the empty-cart redirect, the guest rule, the minimum amount) and deciding, one by one, who owns it now, or writing down that for now nobody does.
If you want to look at the code, it lives in mage-obsidian/module-checkout, and the documentation at mage-obsidian.jeanmarcos.dev. If you turn it on in a store with real payment extensions, I would very much like to know what breaks.