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. It had been saying so 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. 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. The cache itself was the back-end developers’ work. My half was the front end, which meant the header and every template that now had to survive being cached.
Category pages went from a little under 2 seconds to around 300 ms. I have never been part of a bigger single improvement to anything, and 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 kept saying it was in stock.
Three of those are funny. The fourth is a customer service call about an order nobody can fill. The client led with that one, which is fair.
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.
The cache assumes that this page looks the same to everybody, and nobody says that part out loud.
For a category listing that’s very nearly true. It’s the same products in the same order at the same prices with the same markup all the way down the page. My rough count was somewhere around 95% of the bytes.
The other 5% is the header.
Caching is a promise that data won’t change. Usually that means the page won’t go stale over time. Here it means the page doesn’t differ depending on who is looking at it, which is the same promise along a different axis.
A store’s header breaks that promise constantly and on purpose 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. 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 and the header is on every page of the store.
So the switch that reads “don’t cache the greeting” is really the switch that reads “turn the cache off.” The hit rate told us so within a minute.
The second option is to vary the cache by session. Now every visitor gets a private copy of every page they touch and 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.
The third option is to cache the page and patch the header up in JavaScript afterward. That one’s warmer and I think it’s more or less where everybody ends up. But done without care, the header visibly changes a beat after the page has drawn. The cart says 0 and then it says 3.
It’s the same kind of problem as a web font swapping in late. To anybody who didn’t build it, that’s just the page being broken.
The hole is cached too
You don’t want an exception to the cache. You want a hole in it, and the hole gets cached along with everything else. That’s the framing that finally got this into my head.
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. After the page has drawn, one request 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.
The theme was a previous agency’s build and its header rendered the customer’s name in PHP, the way that header had always done it. 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. 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.
But 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 fetches 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. 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
I’ve ended up with two rules, and both are about the moment before the answer arrives instead of the answer.
Reserve the space. The placeholder is the size of the filled version so nothing moves when the number lands. It’s the same discipline as putting width and height on an image and it’s there for the same reason. That’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. 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 most people 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. The real problem is a cache that should have been purged the moment the product was saved and wasn’t. Different problem, different tool.
I think 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 really varies per person, cut closely around it, and serve everything else off the fast path.
The cache config was never the difficult part. Being honest about the size of the personal piece is. I don’t think anybody is honest about it, 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. My list had four things on it and I found all four in production, which is a considerably worse way to write it.