Dansday

The Month I Stopped Invalidating the Cache

Published on Sep 30, 2025

Eighteen pull requests in September and forty-one contributions. One of them adds nothing at all and deletes 1,151 lines, and as I write this it is still open.

Eleven months of invalidating

This site sits behind CloudFront, so a page a customer requests is usually served from a cache rather than from the application. Which means that when somebody in the admin panel changes a price, the cached copy has to be thrown away or the change is invisible.

The way this codebase answered that grew for a year:

Nov 2024  ProductCacheInvalidation, 270 lines, listeners on product changes
May 2025  RateLimitedCloudfrontRefreshJob, debouncing invalidations 10 minutes
Jul 2025  Fix livestream invalidation (two attempts)
Aug 2025  Fix livestream jobs, 161 additions

Every one of those was a reasonable response to a real problem. In November the invalidation was crashing on a variant that did not exist yet. In May the livestream header changed content often enough to hammer a metered AWS API, so I wrote a debouncer that coalesced paths, recognised that a wildcard invalidation subsumes individual ones, and delayed everything ten minutes — and I was pleased with it. In July and August I fixed the scheduled jobs that drove it.

By September the machinery was four listeners, two queued jobs, two console commands, a path resolver, a CloudFront client, a config file and a test suite.

Ten minutes instead

On 9 September I merged six lines of nginx configuration.

~^/(admin|checkout|api|warranty)/.*$ 'private,no-cache,no-store,must-revalidate,max-age=0';
default 'public,max-age=600,stale-while-revalidate=900,stale-if-error=1800';

Ten minutes of cache on everything public. Admin, checkout, API and warranty pages are never cached at all, which they should not be. And two directives that do most of the work: stale-while-revalidate means a request arriving after the ten minutes gets the old copy immediately while a fresh one is fetched behind it, and stale-if-error means that if the application is down, visitors get a stale page for half an hour instead of an error.

The same change also stops PHP from having an opinion:

+ proxy_hide_header Cache-Control;
+ fastcgi_hide_header Cache-Control;
+ add_header Cache-Control $header_cache_control always;

Strip whatever the application sent, and set the header at the edge regardless. One place decides how long a page lives.

What that trades is freshness for simplicity. A price change now takes up to ten minutes to appear rather than being pushed out within seconds. For a shop selling used phones, ten minutes is nothing. For the year of engineering built to avoid those ten minutes, it is everything.

The pull request that only deletes

The same day, I opened this:

removed  src/Product/ProductCacheInvalidation.php              270
removed  src/Console/Commands/InvalidateCloudFrontCache.php    231
removed  src/Console/Commands/HandleLivestreamTransitions.php  157
removed  tests/CloudFrontTest.php                              111
removed  src/Jobs/RateLimitedCloudfrontRefreshJob.php          106
removed  src/Aws/CloudFront.php                                 43
removed  src/Jobs/CloudfrontRefreshJob.php                      42
removed  src/Listeners/ThemeCustomizationListener.php           41
removed  src/Product/Listener.php                               36
removed  src/Aws/PathResolver.php                               29
removed  src/Page/Listener.php                                  24
removed  src/Category/Listener.php                              19
removed  config/invalidation.php                                19
   ... plus config and provider wiring

Eighteen files, 1,151 deletions, zero additions. Every listener, both jobs, both commands, the client, the resolver, the config and the tests. Once nginx decides how long a page lives, none of it has a job.

It has been open since 9 September and it has not been merged. I understand why, and I am not entirely sure it is wrong. Deleting a system is scarier than adding one, because the machinery is currently harmless — it fires invalidations nobody needs against a cache that expires anyway. The risk of leaving it is that it stays in the codebase as something a future developer has to read and take seriously. The risk of merging it is that some path depends on invalidation in a way none of us has thought of, and we find out in production.

So the honest state of it is: the replacement is live, the thing it replaced is still there, and the pull request that would finish the job is sitting in review. That is a very common way for cleanups to end, and it is the first time in this series I can point at one while it is still unresolved.

The voucher applies itself now

The largest feature of the month puts the best available voucher on the product page — 943 additions, 528 deletions, twenty-one files.

1. For a selected variant, the best available delivery voucher (as surfaced
   on the checkout page) will also be displayed on the PDP.
2. The voucher will be auto-applied:
   - Show a small tick to indicate activation.

Read that second line against November 2024, when I built delivery vouchers to the following specification, quoted here exactly as it was written then:

1) Delivery Vouchers are NOT auto-applied on checkout.
Why: This provides a sense of accomplishment/achievement for customers -
because they perceive they've been given something special.

Ten months later the same discount applies itself, announces that it has done so with a tick, and does it a page earlier than before. The reasoning has flipped from making a saving feel earned to making it visible as early as possible.

I have no idea which theory is correct and neither, I suspect, does anybody else — that is what the exit-intent throttling and the popup timers and the review randomisation have all been about. What I can say is that both were argued confidently, ten months apart, by the same team, about the same discount. When a series of tickets keeps reversing itself, that is usually a sign the answer is being measured rather than reasoned, which is the correct way round even though it makes the git history look indecisive.

Telling Google the same number

The consequence of showing post-voucher prices is that you now have to show them everywhere, and the last place anybody thinks of is the one that matters commercially:

Google Shopping feed currently sends pre-voucher prices, causing a
mismatch between what users see on Shopping ads and the prices on PDP
(which already show after-voucher prices). This leads to confusion and
potential drop-offs.

An advert quoting one price and a landing page quoting a lower one is a confusing experience and, from Google's side, a feed-quality problem. So three pull requests in the last week of September pushed the same voucher logic into the product listing pages, the product cards and the Shopping feed.

The same number now has to be computed identically in four places: the product page, the listing page, the checkout, and an XML file a crawler reads. There is no single function that owns it, which means the next promotional mechanism the business invents will need finding in four places rather than one. I noticed that while writing this and I have not fixed it.

Five pull requests that removed things

The middle of the month was a cleanup pass, and I like the shape of it. Five pull requests, all titled UI Cleanup or UI Bug, and their net effect is negative:

Remove PDP ATF UVP                             +2/-11
Remove 'Pick Another Option' from Low Stock CTA  +0/-9
PDP is scrollable to left/right                 +3/-8
UI Cleanup: Instalment Section                  +7/-5
Realign WhatsApp Desktop                        +2/-2

The value-proposition badges above the fold on the product page — the ones customers were tapping in March because they looked like buttons, so I made them tappable — were removed entirely six months later. The Pick Another Option link came out of the low-stock state I built in November 2024.

There is also a pull request called PDP is scrollable to left/right, which is the bug where a page is one or two pixels wider than the screen and can be dragged sideways. Three additions, eight deletions, and it is one of those defects that nobody reports and everybody notices.

And a one-character change: the minimum second payment on a split payment dropped from RM200 to RM100. One line, and it makes the feature available to a whole band of cheaper devices.

A maps bill

Twice this month I reduced use of the Google Maps API. Early in September a P0 ticket covered preloading product and banner images along with the Maps API. At the end of the month I filed and fixed my own issue, and the diff is what tells you what happened:

resources/js/google-maps-loader.js   +4/-39
resources/js/map.js                  +6/-33

Seventy-two lines removed against ten added, from the Places API integration on the store locator. A metered Google API, called more often than it needed to be, found by looking at a usage graph — which is the same story as May's CloudFront invalidations and, for that matter, the same story as this whole month.

"Before" became "New", in one of two places

Eleven additions, nine deletions, two files, titled PDP Savings Clean Up. I skipped it as cosmetic. It is not.

The product page had shown savings as a red pill badge reading SAVE RM900, with the struck-through reference price sitting separately and unlabelled. This replaced it with one line: the reference price, labelled, then the amount saved.

+  <span class="pickedBeforePrice ... line-through">
+      {{ __('New: ') }} {{ Option::formatPrice($beforePrice) }}
+  </span>
+  <div class="saving ...">
+      {{ __('Saved ') . Option::formatPrice($save) }}
+  </div>

Labelling that number is the right call and it is the honest one. An unlabelled struck-through price next to a lower price implies the shop used to charge the higher figure. It never did — that is what the device costs new. Writing New: in front of it says so.

Here is the part that belongs in this series. That price appears twice on the product page: once rendered by the server, and once rewritten by JavaScript when a customer picks a different storage size or colour. This pull request changed the server-rendered one. The JavaScript one kept saying "Before: " until 24 December, three months later, when it was corrected inside a pull request about swipe-gesture sensitivity.

So for three months the product page said New: RM4,199 when it loaded, and Before: RM4,199 the moment you chose a different variant. The same label, in two files, changed one at a time — which is the habit I have been naming all year in other forms: the storage sorting that existed twice, the post-voucher price computed in four places, a customer's name held in two sets of form fields. I wrote about the fourth of those in this very article. I did not notice I had just created a fifth.

The same pull request also hid the product name on mobile, replacing an h1 with hidden xl:block, on the reasoning that the name is already in the header and the space is better spent on price. I would want to know what that did to search results before I called it finished, and nothing in the ticket suggests anybody asked.

Also unrecorded from September: CIMB joined the self-serve credit-card instalment list — one line of config plus the logic to default the dropdown back to Maybank when it is hidden — which is the third bank added to that list in seven months, and the quiet reason the checkout's payment section keeps needing rework.

What September was

Eighteen pull requests. A cache that expires on its own, prices that include the discount everywhere including the advert, five pieces of interface deleted, and a maps integration that costs less.

The through-line is subtraction. The nginx change, the 1,151-line deletion, the removed badges, the trimmed Places calls — almost everything of value this month was taking something out. I spent the first year of this job adding capability and the last few months discovering that a decent share of what I added was answering a question I could have declined to ask. Ten minutes of stale cache would have been an acceptable answer in November 2024. Nobody offered it, including me, because building the invalidation system was more interesting than questioning whether it was needed.

Revised: this article originally treated the savings-display change as cosmetic and left it out. It is the first half of a price label that stayed inconsistent for three months, and it is a fifth instance of the duplication habit this article already discusses. The section above was added from the original diff.