Symptom
On a cold CDN cache with no service worker installed (first-time visitor, or any client whose SW isn't controlling the page), MapLibre's PMTiles reader fails to load the vector basemap. The browser reports net::ERR_CONTENT_DECODING_FAILED on basemap.pmtiles Range requests — roughly 10–14 times per page load — MapLibre logs source errors, and the vector basemap never appears. Reproduces on both production (https://docgerd.github.io/sail_command/) and UAT (https://docgerd.github.io/sail_command/uat/); on UAT the failure surfaces the red “Kartendaten konnten nicht geladen werden” banner.
Root cause
GitHub Pages' Fastly CDN gzips the application/octet-stream basemap.pmtiles response and then serves Range slices of the compressed representation. PMTiles is a range-request format: the reader issues Range: bytes=… requests for header/directory/tile byte offsets. When the CDN returns a 206 whose body is a slice of the gzip stream (not of the original file), that slice is a truncated, un-inflatable gzip fragment — the browser cannot decode it, hence ERR_CONTENT_DECODING_FAILED.
The on-disk artifact is pristine; only partial + gzip breaks. The client cannot avoid it: Accept-Encoding is a forbidden header name, so page JS cannot force identity on the fetch. This is a known failure mode of hosting PMTiles behind a CDN that compresses octet-stream and range-slices the compressed form.
Evidence
Probed with curl against the live prod/UAT URLs:
Range: bytes=0-1023 with Accept-Encoding: gzip → 206, content-encoding: gzip, content-range total 27192908 (the compressed size), body begins 1f 8b (gzip magic) — a truncated, un-inflatable gzip fragment.
Range: bytes=0-1023 with Accept-Encoding: identity → 206, no content-encoding, content-range total 27201789 (the true file size), body begins with the PMTiles magic and is byte-identical to the committed basemap.pmtiles first 1 KiB.
- Full-file
GET --compressed inflates to a payload whose sha256 exactly matches the committed artifact — confirming the file itself is intact; the CDN is corrupting only the partial+gzip case.
- Persistent and edge-wide, not a single poisoned node: reproduced 3× across 3 distinct Fastly edge nodes on prod, and on UAT with
x-cache: MISS (served fresh from origin).
User impact
Affected now: any first-time visitor, and any client without a healthy controlling service worker, on both prod and UAT. MapLibre's PMTiles source Range-fetches from the network and gets un-decodable slices, so the vector basemap is absent on the very first experience of the app. Severity is real and current — production first-load users are hitting this today.
Unaffected: returning visitors with a healthy installed SW. sw.ts precaches the whole .pmtiles at install via a full GET (not a Range request), and its .pmtiles Range→206 route answers subsequent range reads locally from that cached full body — so the CDN is never in the loop for those clients. Returning users recover on the next SW update / re-precache.
Why now (trigger vs root)
The bug is latent and cache-state-dependent: earlier same-day checks were console-clean because the CDN edge was still serving previously-cached identity-encoded Range objects. Today's two develop-push redeploys (5a20940, c7458c9) did not change any bytes — all five build artifacts were ~70.45 MB with no anomaly — but the redeploy evicted those cached identity Range objects; on repopulation the edge began serving the gzip ones, making the latent bug visible.
Implication: redeploying is not a fix — it merely re-rolls which representation the edge caches first. The root cause (CDN compressing + range-slicing) is independent of our build.
Fix directions (to be designed)
Implementation-time decision; not picking here. Any fix must preserve the sw.ts invariant that the .pmtiles Range→206 route stays registered before precacheAndRoute (CLAUDE.md) — that is exactly what makes SW-path clients immune.
- (a) Make first load not depend on network Range requests. Gate map initialization on SW precache-ready, or full-fetch the
.pmtiles once and serve it to MapLibre from an in-memory / OPFS PMTiles source. The app already precaches ~44 MB at install, so a one-shot full fetch is not out of character — but the map-before-SW-ready UX needs design (what the map shows before the full body is available).
- (b) Rehost
basemap.pmtiles on a host that serves identity byte-ranges (e.g. Cloudflare R2 / S3). Weigh against the repo's deliberate no-backend / single-origin simplicity.
Acceptance criteria
Filed 2026-07-23. Diagnosed against the live prod + UAT deployments.
Symptom
On a cold CDN cache with no service worker installed (first-time visitor, or any client whose SW isn't controlling the page), MapLibre's PMTiles reader fails to load the vector basemap. The browser reports
net::ERR_CONTENT_DECODING_FAILEDonbasemap.pmtilesRange requests — roughly 10–14 times per page load — MapLibre logs source errors, and the vector basemap never appears. Reproduces on both production (https://docgerd.github.io/sail_command/) and UAT (https://docgerd.github.io/sail_command/uat/); on UAT the failure surfaces the red “Kartendaten konnten nicht geladen werden” banner.Root cause
GitHub Pages' Fastly CDN gzips the
application/octet-streambasemap.pmtilesresponse and then serves Range slices of the compressed representation. PMTiles is a range-request format: the reader issuesRange: bytes=…requests for header/directory/tile byte offsets. When the CDN returns a 206 whose body is a slice of the gzip stream (not of the original file), that slice is a truncated, un-inflatable gzip fragment — the browser cannot decode it, henceERR_CONTENT_DECODING_FAILED.The on-disk artifact is pristine; only partial + gzip breaks. The client cannot avoid it:
Accept-Encodingis a forbidden header name, so page JS cannot forceidentityon the fetch. This is a known failure mode of hosting PMTiles behind a CDN that compressesoctet-streamand range-slices the compressed form.Evidence
Probed with
curlagainst the live prod/UAT URLs:Range: bytes=0-1023withAccept-Encoding: gzip→206,content-encoding: gzip,content-rangetotal 27192908 (the compressed size), body begins1f 8b(gzip magic) — a truncated, un-inflatable gzip fragment.Range: bytes=0-1023withAccept-Encoding: identity→206, nocontent-encoding,content-rangetotal 27201789 (the true file size), body begins with the PMTiles magic and is byte-identical to the committedbasemap.pmtilesfirst 1 KiB.GET --compressedinflates to a payload whosesha256exactly matches the committed artifact — confirming the file itself is intact; the CDN is corrupting only the partial+gzip case.x-cache: MISS(served fresh from origin).User impact
Affected now: any first-time visitor, and any client without a healthy controlling service worker, on both prod and UAT. MapLibre's PMTiles source Range-fetches from the network and gets un-decodable slices, so the vector basemap is absent on the very first experience of the app. Severity is real and current — production first-load users are hitting this today.
Unaffected: returning visitors with a healthy installed SW.
sw.tsprecaches the whole.pmtilesat install via a full GET (not a Range request), and its.pmtilesRange→206 route answers subsequent range reads locally from that cached full body — so the CDN is never in the loop for those clients. Returning users recover on the next SW update / re-precache.Why now (trigger vs root)
The bug is latent and cache-state-dependent: earlier same-day checks were console-clean because the CDN edge was still serving previously-cached identity-encoded Range objects. Today's two develop-push redeploys (
5a20940,c7458c9) did not change any bytes — all five build artifacts were ~70.45 MB with no anomaly — but the redeploy evicted those cached identity Range objects; on repopulation the edge began serving the gzip ones, making the latent bug visible.Implication: redeploying is not a fix — it merely re-rolls which representation the edge caches first. The root cause (CDN compressing + range-slicing) is independent of our build.
Fix directions (to be designed)
Implementation-time decision; not picking here. Any fix must preserve the
sw.tsinvariant that the.pmtilesRange→206 route stays registered beforeprecacheAndRoute(CLAUDE.md) — that is exactly what makes SW-path clients immune..pmtilesonce and serve it to MapLibre from an in-memory / OPFS PMTiles source. The app already precaches ~44 MB at install, so a one-shot full fetch is not out of character — but the map-before-SW-ready UX needs design (what the map shows before the full body is available).basemap.pmtileson a host that serves identity byte-ranges (e.g. Cloudflare R2 / S3). Weigh against the repo's deliberate no-backend / single-origin simplicity.Acceptance criteria
/uat/.ERR_CONTENT_DECODING_FAILEDin the console.sw.ts.pmtilesRange→206-before-precacheAndRouteinvariant is preserved (SW-path clients remain immune).Filed 2026-07-23. Diagnosed against the live prod + UAT deployments.