Skip to content
Pipfold

The deploy was green, the player still saw the old game

· shipping · tooling

A Godot web export is four files that have to match: the index.html, a loader, the .pck with the data and the .wasm with the program. Plus a service worker that stores all of it so the game also starts without a network.

That intermediate layer is the problem. A service worker exists in order to serve old files. If it holds the wrong idea about which ones are old, it will keep serving them — for as long as you like.

The finding

After shipping, the browser kept running the previous build. Not for a few minutes: permanently, across several reloads. The new version was on the server, with no-store in the response. Measured, the old one still arrived.

The cause

Cloudflare overwrote Cache-Control for .js with the browser TTL of the zone — four hours. So the old index.service.worker.js stayed valid in the browser, the old cache stayed valid, and the player kept running the old build.

This is the unpleasant kind of bug: every single place looks correct. The server sets the right header. The worker does what a worker does. The browser obeys what it was told. It was just told something other than what we wrote.

Two ways, and we took both

One needs Cloudflare access: set browser_cache_ttl = 0 — “Respect Existing Headers”. That is the right setting and it is in place now. Reverting would be

PATCH /zones/<id>/settings/browser_cache_ttl   {"value": 14400}

The other does not — and it is the more important one. On export, deploy.sh writes the build fingerprint into the file names of the loader and the service worker:

index-a58ca516be.js
index-sw-a58ca516be.js

and updates the references in index.html and inside the worker. The index.html itself is served as no-store — Cloudflare respects that — so it always fetches the new names. An old cache entry can no longer keep an old version alive: it points at a file name nobody requests any more.

Rejected: trusting the correct setting. It is correct now — but it lives in an account other people can operate too, and it applies per zone. The fingerprint in the file name is belt and braces, and it costs ten lines in the deploy script. A guarantee whose violation you only notice weeks later is not a guarantee.

Measured live, not assumed

The evidence: a browser still holding the old worker (index.service.worker.js) was running on index-sw-a58ca516be.js and the new loader after one ordinary reload. No cache clearing, no developer mode.

That is the difference between “fixed” and “should be fixed”. Measured against the source, both look the same.

A postscript that took almost four weeks

This story had a second half. The same zone setting was still at 14400 for pipfold.com when that domain joined in August — and it did the same thing there, only to a different file. It only surfaced when a version installed on an iPhone re-downloaded 39 MB on every open.

A trap you have understood once still snaps shut on the next hostname. The previous value now sits in ~/.config/mana/cf-browsercachettl-pipfold-vor-20260819.json — so the next zone at least has something to look up.


A side finding from the same day, because it looked like a bug in the game for a long time: freeze frames mid-throw. The roll would hang, the dice stood in the air. That was not a bug in the animation but the throttling of a background tab in Chrome — document.hidden, requestAnimationFrame does not fire. Check document.visibilityState before that kind of diagnosis.

← Devlog