The cart is not a page
The trade at the middle of every fast site, and the header that knows your name.
We put full-page caching in front of a store in January. Category pages went from about 1.9 seconds to a bit under 300ms, which is the largest single improvement I have ever been responsible for and I would like that on the record.
It also broke four things:
- The cart count in the header showed whatever the previous visitor had.
- “Welcome back, Susan” greeted everybody as Susan.
- A product that had gone out of stock kept saying it was available.
- The “recently viewed” strip showed somebody else’s recently viewed.
Three of those are funny. One of them is a support call about an order that can’t be fulfilled. But they’re all the same bug, and the shape of that bug is the thing I actually want to write down, because it turns out to be the central trade in making anything fast.
What the cache assumes
A full-page cache stores the finished HTML for a URL and serves it to the next person who asks, without running any of the code that produced it.
That is the entire win. You skip the database, the templates, the whole render. And it only works because of an assumption sitting underneath it that nobody states: this page is the same for everybody.
For a category listing that’s very nearly true. Same products, same order, same prices, same markup. It’s true for approximately 96% of the bytes on the page.
The other 4% is the header.
The cache asks one question, is this page the same for everybody, and a store's answer is "yes, apart from the corner where it says how many things you're buying."
Three wrong turns
Don’t cache pages with a cart on them. Every page has a cart in the header. This excludes the entire site and I’m slightly embarrassed to have typed it.
Vary the cache by session. Now every visitor gets their own copy of every page, the hit rate goes to roughly zero, and you have built an extremely elaborate way of not having a cache while paying for the storage.
Cache it and fix it up in JavaScript afterward. Getting warmer, and this is broadly where everyone lands, but done naively it means the header visibly changes a beat after the page draws. Your cart says 0, then says 3. It’s the same class of problem as a font swap and it looks like a bug to everybody who isn’t you.
The actual model: punch a hole
The framing that made it click for me is that you don’t want an exception to the cache, you want a hole in it.
The page is cached, entirely, including a placeholder where the personal bit goes. The placeholder is part of the cached HTML, the same for everyone, so the cache is happy. Then one small request fills every hole at once.
<!-- cached with the page, identical for every visitor -->
<div data-hole="cart-summary"></div>
<div data-hole="customer-greeting"></div>
// one request, after paint, filling all of them
fetch('/customer/section/load?sections=cart,customer')
.then(r => r.json())
.then(fill);
One request, not one per hole. That matters more than it sounds: the first version I wrote made three, and three round trips on a phone on a train is most of a second for information that fits in a tweet.
The rules I’ve ended up with:
Reserve the space. The placeholder has the dimensions of the filled version, so nothing moves when the answer arrives. Same discipline as an image, same reason.
Design the empty state as a real state. Not a spinner. The cart icon with no number is a perfectly good thing to show somebody for 200ms, and it’s what most visitors should see anyway because most visitors have an empty cart.
Keep stock out of it. This is the one I’d argue about. Availability is not personalization. It’s the same for everybody, it’s just fresh. Solving it with the personalization mechanism means every visitor pays a request for a fact that could have been handled by invalidating the cache when stock changes. Different problem, different tool, and conflating them is how the 4% becomes 14%.
Where to put the seam
I’ve since noticed this is not a caching pattern, it’s the pattern.
Any time you want something fast and something personal, you are choosing where to put the seam. You can make the whole page personal and slow. You can make the whole page general and wrong. Or you can find the smallest possible piece that genuinely varies, cut precisely around it, and serve everything else from the fast path.
The skill isn’t the caching config. It’s being honest about how big the personal part really is, because the instinct is always to overestimate it. The header feels personal, the page feels personal, and then you measure and it’s one number and a name.
Four bugs, one shape
And the shape was that we’d cached a page which was 96% identical for everybody and then acted surprised about the other 4%.
The cart count still shows up a beat late on a slow connection. I’ve stopped minding. It shows up in the right place, at the right size, with nothing jumping, and the category page loads in under 300ms for everyone who has never bought anything, which is most people.