Your category pages are fast. Your product pages are fast. Then a customer clicks "Checkout" and the store falls off a cliff — a spinner between every step, a two-second pause after "Continue," a totals box that recalculates while they wait with their card in hand.
That's not bad luck, and it's usually not "Magento is just slow." Checkout is the one part of a Magento store that the Full Page Cache cannot help you with, and that changes everything about how you have to make it fast. This post is the architecture of why — and the five causes I find again and again, in the order they're usually worth fixing.
If you only take one thing from this: a slow checkout is the most expensive slowness you have, because every millisecond is spent on a customer who already decided to buy.
The thing nobody tells you: checkout is an app, not a page
A Magento category page is HTML. Render it once, cache it in Varnish, serve it to the next 10,000 visitors in single-digit milliseconds. Done.
Checkout doesn't work like that. The Magento 2 checkout is a Knockout.js single-page application built from UiComponents, running against a stack of REST endpoints. It's personal to one customer's quote, it changes on every interaction, and it can't be page-cached. So all the caching that makes the rest of your store fast — Varnish, FPC — does almost nothing here.
That means checkout performance is governed by four things the FPC never touches:
- How much JavaScript the checkout app loads and executes.
- How many AJAX round-trips it makes, and how slow each one is.
- How expensive your quote totals collection is on the server.
- What third-party code you've let mount inside the checkout.
Every real checkout slowness I've diagnosed lives in one or more of those four. Here they are as five concrete causes.
Cause 1 — Totals are recollected on every interaction
Every time the quote changes — address entered, shipping method picked, coupon
applied — Magento runs collectTotals(). That walks every registered total
collector: subtotal, tax, shipping, discount, weee, gift card, store credit,
and crucially every total model your extensions registered.
On a clean store this is fast. On a store with a tax extension, a loyalty extension, a custom promotions engine, and a gift-card module, you can have a dozen collectors firing on every keystroke-driven recalculation — each one potentially loading config, hitting the database, or worse, calling an external API inline.
How to confirm it: put a transaction trace on the
rest/V1/carts/mine/totals-information (or guest equivalent) endpoint with
Blackfire or New Relic and look at what collectTotals fans out into. If a
third-party collector is doing a SQL query or an HTTP call per collection, you've
found it.
The fix is architectural, not a setting: audit the registered collectors, remove the ones tied to modules you don't use, and push anything that calls an external service out of the synchronous collection path. A tax API that has to be called live should be cached per address hash, not re-called on every totals refresh.
Cause 2 — Serial, blocking AJAX between steps
Open your browser's network tab and walk through checkout. You'll see a chain:
estimate-shipping-methods → set-shipping-information →
payment-information. These fire largely in sequence, and the UI blocks on
each one — that's the spinner.
Now imagine your shipping rates come from a live carrier API (UPS, DHL, a 3PL). If that API takes 1.2 seconds, your customer stares at a spinner for 1.2 seconds, every time they change an address field that retriggers a rate request. The checkout is only as fast as the slowest API in the chain, and that API is often not yours.
The fixes, in order of leverage:
- Cache shipping rates server-side, keyed on the destination + cart signature, with a short TTL. The same address shouldn't hit the carrier twice in one session.
- Set aggressive timeouts with graceful fallback. If the carrier API is slow, show a flat/estimated rate and reconcile, rather than holding the whole checkout hostage to a third party's bad day.
- Debounce the address-change triggers so a customer typing their postcode doesn't fire five rate requests.
- Where the platform allows it, parallelize the calls that don't depend on each other instead of chaining them.
Cause 3 — The JavaScript the checkout has to load
The checkout app is a RequireJS dependency graph, and checkout_index_index's
jsLayout defines which UiComponents mount. Every extension that touches
checkout adds to that graph — its own components, its Knockout mixins, its
templates.
The symptoms are a long main-thread block right as the checkout step renders, and a poor INP when the customer first interacts. This is the part of checkout that Core Web Vitals actually sees.
The fixes:
- Bundle and minify properly —
productiondeploy mode with JS bundling, or better, Baler/merged bundles so the checkout isn't fetching 80 small modules. - Prune the
jsLayout— modules frequently inject UiComponents you don't use (extra payment renderers, agreement blocks, upsell widgets). Remove them in layout XML so they never mount. - Defer non-essential components. A trust-badge widget does not need to be in the critical render path of the payment step.
Cause 4 — customer/section/load storms and the mini-cart problem
Magento keeps per-customer dynamic data (cart contents, customer name, compare
list) in customer sections, loaded via /customer/section/load. Add to
cart, update a quantity, apply a coupon — sections invalidate and reload.
Two failure modes I see constantly:
- Over-broad invalidation: a poorly written extension marks all sections dirty on every action, so a single "add to cart" triggers a full section reload of data it didn't change.
- The mini-cart busting FPC: if dynamic cart data isn't isolated to sections / ESI properly, a developer "fixes" a stale mini-cart by disabling page cache on pages that should be fully cached — trading a checkout bug for a site-wide performance regression.
The fix is getting sections.xml invalidation scoped correctly — each action
invalidates only the sections it actually changes — and keeping dynamic cart
rendering on the ESI/section path so the rest of the page stays fully cacheable.
Cause 5 — Third-party scripts mounting inside checkout
This is the one that's almost never in the Magento code at all. Tag managers, chat widgets, session-recording tools, A/B testing snippets, address-autocomplete and fraud-scoring scripts — all competing for the main thread at the exact moment the customer is trying to pay.
A heavy tag manager firing twenty tags on the payment step can add more delay than every server-side fix above combined. And because it's injected through GTM rather than the codebase, your developers often don't even know it's there.
The fixes:
- Audit what actually loads on
/checkout— not what's supposed to load, what the network tab shows. - Defer or conditionally load anything non-essential to completing the purchase. Analytics can fire on the success page.
- Move to server-side tagging where the volume justifies it, so the customer's browser isn't doing the marketing team's work mid-payment.
How to actually measure this — don't guess
You cannot fix a checkout by reading this list and picking your favourite cause. The whole point is that four stores with an identical slow checkout can have four different root causes. Measure first:
- Field data, not lab. Look at INP on the checkout URL in real-user monitoring (CrUX, your RUM tool). The lab score on an empty cart lies.
- The network waterfall of a real checkout session — time
estimate-shipping-methodsandpayment-informationspecifically. Slow there = server or third-party API; fast there but janky = JavaScript. - A server-side transaction trace (Blackfire / New Relic) on the checkout
REST endpoints, so you can see
collectTotalsfan-out and any inline external calls. - The MySQL slow query log during a checkout, to catch a collector or observer doing unindexed queries.
Then fix in order of measured impact — a 1.2s shipping API beats a 150ms JS saving, every time, no matter which one is more fun to optimize.
The order I'd fix them
For a typical $1–10M Magento store with a slow checkout, the leverage usually ranks like this:
- Third-party scripts on checkout (Cause 5) — often the single biggest win, and the cheapest, because it's config not code.
- Blocking shipping/payment APIs (Cause 2) — caching + timeouts.
- Totals collector bloat (Cause 1) — remove and de-inline.
- JS layout / bundle (Cause 3) — bundling +
jsLayoutpruning. - Section invalidation (Cause 4) — scope it correctly.
That order isn't universal — it's what the measurement usually shows. Your store might invert it entirely. That's exactly why you measure before you touch anything.
Want to know which of these is costing you checkouts — with the numbers from your store? The checkout path is one of the eight areas in my 40-point Performance Audit: $1,500 flat, 5 working days, a prioritized PDF action plan, and a 1-hour call. Book the audit →
Not ready to book? Tell me about your store — I answer architecture questions even when they don't turn into work.