Everyone was Susan
A customer emailed the store to ask who Susan was.
Nobody on our side knew either, for a while. The header on every page of the site said “Welcome back, Susan” to every person who loaded it, logged in or not, and had been saying it since some point that morning. Susan is a real customer of theirs. Susan happened to be the first person to log in and hit the homepage after the cache last emptied, so Susan’s header got stored, and then everybody who arrived over the next few hours was handed Susan’s header.
We had put a cache in front of that store about 10 days earlier. Category pages went from a bit under 2 seconds to around 300 ms. I have never been responsible for a bigger single improvement to anything, before or since. I want that on the record before we get any further into Susan.
At a high level, a full-page cache stores the page the first person gets and hands that same page to everybody who arrives after them. That’s the whole feature. It’s also, in its entirety, the bug.
It broke four things:
- The cart count in the header showed whatever the previous visitor had in their cart.
- The greeting.
- The “recently viewed” strip showed somebody else’s recently viewed.
- A product that had sold out went on saying it was in stock.
Three of those are funny. The fourth is a customer service call about an order nobody can fill. So it’s the one the client led with, fairly.
They’re also all the same bug, which took me the better part of a day to see.
What the cache is promising
A full-page cache stores the finished HTML for a URL and hands that HTML to the next person who asks for the same URL, without running any of the code that produced it. No database, no layout, no PHP. That’s the entire win, and it’s why the number moves as far as it does. It’s also a blunt instrument, and I’d spent the winter learning to read the queries a page makes right before installing something whose whole job is to not make them.
Sitting underneath it is an assumption nobody says out loud, which is that this page looks the same to everybody.
For a category listing that’s very nearly true. Same products, same order, same prices, same markup, all the way down the page. My rough count was somewhere around 95% of the bytes.
The other 5% is the header.
I wrote a while back, about putting a service worker on a brochure site, that caching is a promise that data won’t change. This is that promise made along a different axis. Not that the page won’t go stale over time, but that it doesn’t differ depending on who is looking at it. A store’s header breaks that promise deliberately, constantly, and by design, because the header is where the cart lives.
Three ways to make it worse
We tried two of these and I got fairly close to the third.
The platform has a switch for exactly this, or it looks like it does. You can mark a block non-cacheable in the layout, and the greeting is a block, and the greeting is the problem, so.
<!-- excludes the greeting, surely -->
<referenceBlock name="header.welcome" cacheable="false"/>
That makes every page containing that block non-cacheable. One uncacheable block anywhere on a page and the whole page goes around the cache. The block is in the header. The header is on every page of the store. So the switch that reads “don’t cache the greeting” is, in practice, the switch that reads “turn the cache off,” and the hit rate told us so inside a minute.
Second option, vary the cache by session. Now every visitor gets a private copy of every page they touch, the hit rate falls to roughly zero. You have built an extremely elaborate way of not having a cache while continuing to pay to store one.
Third option, cache the page and patch the header up in JavaScript afterward. That one’s warmer, and broadly where everybody ends up, but done without care it means the header visibly changes a beat after the page has drawn. Cart says 0, then says 3. It’s the same shape of problem as a web font swapping in late, and to anybody who didn’t build it, that’s just the page being broken.
The hole is cached too
The framing that finally got this into my head is that you don’t want an exception to the cache. You want a hole in it, and the hole gets cached along with everything else.
The page goes into the cache complete, including an empty placeholder where the personal part belongs. That placeholder is identical for every visitor, so the cache is perfectly happy to store it and hand it out. Then one request, after the page has drawn, fills every hole on the page at once.
<!-- in the cached page, the same for every visitor -->
<span class="greeting" data-bind="text: customer().firstname"></span>
<span class="counter-number" data-bind="text: cart().summary_count"></span>
Magento 2 calls this private content and comes with the entire mechanism already built, which is the part that stings a little. It was there the whole time. Our theme had carried over from the store’s previous build carrying a header that rendered the customer’s name in PHP, the way that header had always done it, and not one of us thought to ask why the platform had a whole subsystem devoted to a <span>. It behooves a person to read the docs for the thing they have just been paid to make faster.
There is an older way to do the same job, and it’s worth knowing about before you pick. With ESI the cached page carries a tag where the personal fragment goes, and the cache itself fetches that fragment and stitches the page together before handing it over. It works, and it’s what the previous generation of this platform leaned on. The catch is that the stitching happens at the cache, so a page with a hole in it is now a page the cache has to do work on for every single request. You need somewhere for that sub-request to land that is fast enough to be worth the trouble. Doing it in the browser takes the work off the critical path entirely, and charges you a visibly empty hole for a moment instead. We went with the browser, which is the direction the platform wanted to go anyway.
One request, and one request for all of it:
{
"cart": {
"summary_count": 3,
"subtotal": "$48.00"
},
"customer": {
"firstname": "Susan"
}
}
Susan lives in there. That is the only correct address for Susan.
One request and not one per hole, which matters more than it sounds like it should. The first version I wrote made three of them, and three round trips from a phone with two bars is most of a second spent fetching something that would fit in a text message.
A cart with no number is a cart
Two rules I’ve ended up with, both of them about the moment before the answer arrives rather than the answer.
Reserve the space. The placeholder is the size of the filled version, so nothing moves when the number lands. Same discipline as putting width and height on an image, same reason. It’s the whole difference between “the cart count appeared” and “the page jumped.”
Then design the empty state as a real state instead of as a spinner. A cart icon with no number beside it is a perfectly good thing to show somebody for 200 ms. It’s also what most of them should be looking at once the request comes back, since most people visiting a store have an empty cart. A spinner promises that something is on its way. An empty cart says the cart is empty, and for the majority that turns out to be true.
Stock is not personal
This is the one I’d argue about, and did.
The out-of-stock bug is sitting right there in the same list as the greeting, so the obvious move is to fix it the same way, by adding availability to the sections the header already fetches. It works. I still think it’s wrong.
Availability isn’t personalization. It’s the same for everybody, it’s just fresh. Solving it with the private content request means every visitor pays for a round trip to be told a fact that has nothing to do with them, when the real problem is a cache that should have been purged the moment the product was saved and wasn’t. Different problem, different tool. My sense is that conflating the two is how the personal share of a page creeps from 5% to 20% over a couple of years, one reasonable-sounding ticket at a time.
Where you make the cut
I keep noticing this isn’t really a caching pattern so much as the pattern.
Any time you want something to be fast and you also want it to be personal, you are choosing where to make the cut. You can make the whole page personal and slow. You can make the whole page general and wrong, which is what we did for 10 days. Or you find the smallest piece that genuinely varies per person, cut closely around it, and serve everything else off the fast path.
The cache config was never the difficult part. It’s being honest about how big the personal piece actually is. I don’t think anybody is, including me, on this exact project. The header feels personal. The whole page feels personal, in the way a store you’ve been building for months feels like it knows people. Then you go and measure it and it’s a name and a number.
So if you’re about to put a cache in front of a store, go and do the thing I didn’t do. Open the site in a private window, open it again logged in as a real customer with something in the cart, and put the two windows next to each other. Every difference you can find is a hole you’re going to have to punch. Every difference you can’t find is an email waiting for you in about a week. Mine had four things on it and I found all four in production, which is a considerably worse way to write the list.