-
Notifications
You must be signed in to change notification settings - Fork 0
Caching and Cache Busting
How the site stops browsers and the GitHub Pages CDN from serving stale CSS and JS, and how to work with it day to day.
The site ships raw ES modules and CSS with no bundler. Browsers and the Pages CDN cache those files by URL, and they hold onto them. Without a cache key, a deploy that changes a file can leave visitors on the old version until their cache expires on its own. The fix is to put a content hash in the query string of every asset reference, so a changed file looks like a brand new URL and gets refetched.
scripts/cache-bust.sh rewrites two kinds of references. It runs from make build, and make pre-push calls it first.
- Relative ES module imports inside JS files, for example
import { x } from './foo.js?v=ab12cd34' -
srcandhrefreferences to localcss/andjs/files inside every*.html
The ?v= value is the first 8 hex digits of the md5 of the referenced file. Change the file, the hash changes, every reference to it changes, and the browser and CDN treat it as a new URL.
A JS file's content includes the ?v= of its own imports. So when a leaf module changes, its hash changes, which changes the content of every file that imports it, which changes those files' hashes, and so on up the import graph. One edit to a shared helper busts the cache for the whole chain that depends on it, not just the helper.
The transitive scheme only settles to stable hashes when imports form a directed acyclic graph. The js/app/ tree currently has circular imports (router.js and the component renderers import each other), so the hashes never reach a fixed point and make build re-churns those files on every run. This is tracked in issue #36. Until it is fixed, expect js/app/* ?v= strings to appear as spurious diffs after make pre-push. Commit only the asset files you actually changed.
-
make buildruns cache-bust on its own. -
make pre-pushruns it first, before Jest and the smoke test. - The deploy workflow does not regenerate hashes. It ships whatever is committed. Run
make build(ormake pre-push) and commit the result before deploying, or the live site references the wrong hashes.
Game cover art (Steam, GOG, Epic header images) is served by a service worker, sw.js, so the browse grid paints instantly on repeat visits instead of waiting on dozens of third-party CDN round trips. This is separate from the ?v= scheme above: cache-bust governs the site's own CSS and JS, the service worker governs cover images.
Only image GET requests (request.destination === 'image'). Everything else (HTML, JS, CSS, JSON) passes straight through to the network, untouched, so the ?v= cache-bust stays the single source of truth for site assets and nothing the service worker does can make the app code go stale.
- A cached image is served immediately from Cache Storage. No network round trip, so it paints at once (DevTools shows the request served "from ServiceWorker" at 0 ms).
- On serve, the worker checks the entry's age. If it is older than the max-age (7 days), it kicks off a quiet background refetch that updates the cache for next time. The user still sees the cached copy instantly; the refresh never blocks paint.
- Fresh entries (younger than 7 days) skip the network entirely, so a cover we just fetched is never re-downloaded.
- On a cache miss, the worker fetches, caches, and returns the response.
Cover art almost never changes, so this keeps the paint instant while still healing a changed cover within a week.
- Cache name:
pp-img-cache-v1. Bumping the version (tov2, etc.) is the manual "flush everything" lever; theactivatestep deletes every cache whose name is not the current one. - Capped at 300 entries. Once it exceeds the cap, the oldest entries are trimmed (FIFO), so heavy browsing keeps a rolling window of the most recent covers.
- The browser can also evict the cache under storage pressure, or the user can clear site data.
There is no time-based purge beyond the 7-day revalidation. Entries live until trimmed by the 300 cap, wiped by a version bump, or evicted by the browser.
Cover images load no-cors, so their responses are opaque (status 0, headers unreadable). We cannot stamp a "cached at" header on an opaque response, so the per-URL timestamps used by the max-age check live in a small IndexedDB side table (pp-sw-meta, store ts), keyed by request URL. Entries written before timestamps existed read back as undefined and are treated as stale, so they self-heal on first access.
If the network fetch throws (offline, blip), the worker serves any cached copy, otherwise it returns Response.error() so the existing <img> onerror fallback chain (cloudflare -> game-images.json -> hide) still runs. The 404s you may see attributed to the service worker in DevTools are expected: a game whose primary CDN URL does not exist returns 404 through the worker, the image onerror fires, and the fallback resolves the real cover. Steam's CDN sends CORS headers, so those 404s are readable and the res.ok check skips caching them; only good responses are stored.
Registered from js/lib/topbar.js with a plain script tag (no build step). register('sw.js') resolves relative to the page, so it works at the production root and under the /proton-pulse-web-staging/ subpath alike. Scope is the directory the worker is served from, which controls every page on the site.
The worker counts cache hits and misses for the session. When a page is hidden (visibilitychange -> hidden), topbar.js queries the worker over a MessageChannel, and the worker replies with the counts and resets them so each report is a delta. The page then fires one sw_cache event through window.ppTrack into the site_events table. One aggregate event per session, not one per image.
The admin Analytics tab shows an "Image cache (service worker)" card: overall hit rate, images served from cache, misses, and how many sessions reported. The admin API pulls sw_cache events for the selected range and aggregates them client-side (hit rate plus a per-day series), so no Supabase migration was needed.
| Concern | File |
|---|---|
| Worker (cache-first + SWR + max-age + counters) | sw.js |
| Registration + per-session stats report | js/lib/topbar.js |
Event sink (ppTrack -> site_events) |
js/lib/analytics.js |
Admin aggregation of sw_cache events |
js/admin/api/analytics.js |
| Admin "Image cache" card | js/admin/components/analytics.js |
| Deploy listing (root file) | gh-pages-manifest.txt |
Bump CACHE in sw.js from pp-img-cache-v1 to the next version and deploy. On activate, the worker deletes the old cache for every visitor. Use this only when covers genuinely need to be re-pulled site-wide; normal cover changes self-heal within the 7-day max-age.
- Load
app.html, reload, and watch the Network tab showheader.jpgrows served by "service worker" at 0 ms. - DevTools > Application > Service Workers shows it active; Cache Storage shows
pp-img-cache-v1filling. - Switch tabs or navigate away to fire the stats report, then open the admin Analytics tab and check the Image cache card (needs a few real sessions to populate).
Tracked in #98 (cache) and #103 (analytics).
After a correct deploy, the Pages CDN still holds the previous copy of a file at its edge for a short window, usually seconds to a couple of minutes. Two things follow from that:
- A file you just deployed can briefly still serve the old content. That is normal propagation, not a failed deploy.
- To confirm what actually shipped, bypass the edge cache with a throwaway query string:
curl -s "https://mdeguzis.github.io/proton-pulse-web-staging/admin.html?cb=$(date +%s)"The server ignores the ?cb= value, but the CDN sees an uncached URL and serves the freshly deployed file.
Android Chrome (and other mobile browsers) sometimes cache HTML pages aggressively, ignoring CDN TTLs. The result is the browser loads a stale HTML file that references old ?v= hashes, so none of the new JS or CSS is picked up even after a successful deploy. The about page shows the correct version (it fetches version.json at runtime, bypassing the HTML cache), but the rest of the UI stays on the old build.
The fix applied as of 2026-06-18: every HTML page now includes three meta tags immediately after <meta charset="utf-8">:
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">These tell the browser the HTML must always be revalidated against the server. JS and CSS are unaffected -- they are still cached normally via their ?v= hashes and only refetched when the hash changes.
If a user on mobile reports seeing an old version after a confirmed deploy:
- Ask them to check the about page for the expected version and commit SHA.
- If the about page shows the right version but the UI is wrong, it is a page-level HTML cache. A hard reload (long-press refresh in Chrome, or clear site data for the domain) will fix it.
- The no-cache meta tags should prevent recurrence for deploys after 2026-06-18.
| Task | Command |
|---|---|
Regenerate all ?v= hashes |
make build |
| Regenerate, then test and smoke | make pre-push |
| Verify a deployed file, bypassing the CDN | curl ".../file?cb=$(date +%s)" |
Last verified: 2026-06-24.