Skip to content

First-load vector basemap broken: GitHub Pages CDN gzips basemap.pmtiles and range-slices the compressed form (ERR_CONTENT_DECODING_FAILED) #118

Description

@DocGerd

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: gzip206, 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: identity206, 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

  • First-load vector basemap renders on a cold CDN cache with no SW installed, on both prod and /uat/.
  • Verified with a cache-busted, no-SW browser pass (fresh profile / SW unregistered) showing the basemap present and no ERR_CONTENT_DECODING_FAILED in the console.
  • The sw.ts .pmtiles Range→206-before-precacheAndRoute invariant is preserved (SW-path clients remain immune).
  • No new backend introduced unless direction (b) is explicitly chosen and its trade-off accepted.

Filed 2026-07-23. Diagnosed against the live prod + UAT deployments.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions