The installed version re-downloaded 39 MB on every open
· shipping · tooling
A PWA makes exactly one promise: load once, then it starts. Till had installed Pipfold on an iPhone, and on every open the game downloaded again. So the promise was broken — and with it the argument for why iOS does not need an app in the store.
Four bugs, independent of each other. Two of them would have sufficed alone.
1. The big chunk never reached the cache
Measured in the browser: eight entries in the cache, usage 2.3 MB instead
of 39.
The service worker had everything except index.wasm — 37.7 of 38.9 MB, so
97 % of the payload. The 1.2 MB index.pck got through, the wasm never
did.
Godot stores like this:
cache.put(req, response.clone())
clone() yields a stream body. And Cache.put() rejects a stream over
roughly 16 MB. index.wasm is 37.7 MB and therefore failed every time; the
1.2 MB of the .pck sit far below the threshold and arrived.
So it is not a race and not an intermittent fault, but a hard threshold — reproducible, every time, since day one.
And it was invisible. Godot calls cache.put(...) with neither await nor
catch — the error vanishes as an unhandled promise rejection. No message, no
warning. On top of that, console.error from a service worker does not land
in the page console. Whoever looks there sees nothing and concludes nothing
happened.
On a development machine it never showed either, because the ordinary HTTP cache revalidates with an ETag: 304 instead of 37 MB. The worker was empty and the game still started instantly. On an iPhone, iOS evicts a single resource that large quickly — there the worker was the only thing that could have held the file, and it was precisely the one that did not have it.
Fixed in deploy.sh, which post-processes the generated worker anyway: the
response is buffered once in full and both consumers are served from it —
cache and game. No clone, no race.
A first attempt only stored the clone in the background
(event.waitUntil(manaCachePut(...))). In the source that looks like the fix:
storing no longer blocks, the error is caught. In the browser the cache stayed
at 2.2 MB. Measured against the result, not against the source — otherwise
it would have passed as “fixed” and the whole thing would have sat there for
another fortnight.
2. The patch never reached the browser
It still had no effect at first.
Reason: /spielen/index-sw-*.js matched no rule in the Caddyfile. The
neighbouring entry /index-sw-*.js only hits the root — Caddy’s path
compares the whole path, not the ending. Without Cache-Control Cloudflare
applied its zone default: max-age=14400, cf-cache-status: HIT. The patch was
on the origin, the old version in the browser.
That is worse than it sounds. The loader carries a fingerprint in its name, but
the worker only as long as the .pck changes — the fingerprint is derived
from it. Whoever touches only the worker ships under the same name. Which is
exactly the case here: working on a bug in the worker means using the one route
along which the change cannot arrive.
Fixed: /spielen/index-sw-*.js added to the @frisch matcher, edge purged
once.
3. The zone overwrote all our headers
browser_cache_ttl was set to 14400 for the whole pipfold.com zone.
That made every carefully placed header ineffective: no-cache on the entry
pages became four hours — and the download packages got four hours instead of
immutable (one year).
This is the same bug as on 25 July, only on a different zone. A trap you have understood once snaps shut again on the next hostname, because the setting is per zone and a new domain brings it along fresh.
Set to 0 (“respect origin”). The previous value sits in
~/.config/mana/cf-browsercachettl-pipfold-vor-20260819.json.
4. Installing from the front page produced only a bookmark
The fourth bug is the one most likely to be mistaken for user error: Till had
installed from pipfold.com/ — from the landing page, not from the game.
And the landing page had no manifest and no apple meta tags. On an iPhone that produces a plain web clip: no app identity, no scope, no claim on the game’s cache.
Fixed: both landing pages (/ and /en/) now point at the same manifest as
the game. Deliberately not at one of their own — the browser derives the
identity of an installed app from start_url, and a second manifest would be a
second app on the same home screen, with its own storage and its own save
file.
What is general about this
Four bugs along one path, and three of them do not announce themselves.
An unhandled promise rejection in a service worker, a Caddy rule that misses a path, and a zone setting in somebody else’s control panel have one thing in common: they behave exactly like the normal case. Nothing is red, nothing is slow, nothing is in the log. It is only slower than it needs to be, and you notice that when somebody uses it.
So at the end of this story sits tools/pwa_pruefen.mjs. It checks three
things: that the shipped worker carries the patch — not the one in the
repository —, that it arrives with fresh headers at all, and that Cache.put
accepts 40 MB as a blob, so the mechanism holds.
🔴 What it cannot do: reproduce the installed state. Godot registers the
worker only about 30 seconds after loading, and headless
navigator.serviceWorker.controller stays empty — tried with a persistent
profile, it does not work. The final state has to be checked by hand once, and
the guard prints the recipe itself:
await navigator.storage.estimate() // usage must be > 30 MB
A guard that says honestly where it stops is worth more than one that pretends to cover everything. The second kind is exactly what could have missed this bug for six weeks.