performance
The map that downloaded the world
A static map view produced 10+ MB/s bursts and a warm phone. The render loop was innocent, our first probe fabricated a '93 duplicate requests' finding, and the real villain was the availability feature that keeps fast-pan from blanking — politely fetching an 85-tile, 33 MB world pyramid on every load and pinning it against eviction forever. Same view after the fix: 35.33 MB → 6.27 MB.
By the X-GIS team 6 min read
The report (#1045): a static basemap view — no panning, no animation — bursts past 10 MB/s and the user’s phone gets warm. The report also came with suspects: too many tile requests, request queues coupled to a 144 Hz rAF, wrong zoom selection fetching too many tiles. Zoom selection was already off the list — our tile-decision goldens match MapLibre’s selection for the same cameras. The rest needed measurement, and the measurement is where this story spends its first act, because our instruments lied before the code did.
The harness lied first
Act one was just getting bytes to flow: in this environment the headless
browser’s CONNECT to the egress proxy was reset, so in-browser fetches to
the tile CDN failed outright. The workaround shapes everything after:
page.route intercepts every external request and relays it through a
Node-side APIRequestContext (which speaks the proxy and TLS config
correctly), then fulfills the route. The app’s request behaviour is
untouched — same URLs, same timing, same cache semantics from the page’s
point of view — the harness just does the fetching.
Act two produced the season’s scariest finding: 93 requests, 93
duplicates. Every single tile fetched twice. That number would have
confirmed the user’s queue theory beautifully, and it survived just long
enough to be almost posted as the diagnosis. The cross-check killed it: a
CDP Network.requestWillBeSent count for the same run showed zero
duplicates. The probe was recording each request in both a route handler
and a response listener — it was double-counting its own bookkeeping. A
correction went to the issue and every byte figure in the investigation got
halved. The finding was self-inflicted, and it was more dramatic than the
truth.
(We’ve been burned by an instrument fabricating a finding before — a boundary audit whose regex ate a digit. The rule that survives both incidents: a counter you built five minutes ago has earned no trust; cross-instrument before you believe it.)
The villain was the safety feature
With honest numbers, the traffic distribution answered in one line: a
single static z4 view fetched 85 skeleton tiles ≈ 33 MB — 92% of all
traffic. Not the viewport’s tiles. The skeleton: our Cesium-style
permanent root retention, z0–z3 pinned in cache so that
classifyFallback’s ancestor walk always finds a parent during fast-pan.
The feature is legitimate — before it, panning to a fresh region dropped
into the pending decision and the canvas cleared white; it’s the same
skeleton that let the blank-hemisphere fix
have ancestors to draw at all.
But look at how it spent:
prewarmSkeletonenumerated every tile of every level z0–z3 up front, and re-issued the whole remaining set on a fixed 250 ms tick until all arrived — no backoff, so a lossy mobile link meant a radio kept hot by retries.- The eviction policy pinned every key permanently:
markSkeletonsurvivesevictTilesunconditionally andcancelStalenever aborts its fetches. The pyramid was not just downloaded; it was undeletable. - The cost model was wrong by 8×. The depth heuristic assumed ~50 KB/tile — ~4 MB for depth 3, a defensible ante. OpenFreeMap’s z2–z3 tiles average ~400 KB. The feature was designed in tiles and levels; the bill arrived in bytes.
Nothing errored. Nothing was even slow, on desktop wifi. That’s what makes availability features the perfect traffic villains: they run unconditionally, before any user intent, and correctly — the report they generate is never “the map is broken,” it’s “the map works and my phone is hot.”
The fix: keep the purpose, bound the cost
The pump moved into its own module (data/src/tile-skeleton-prewarm.ts)
with four properties, each answering one measured failure:
- Level-staged — z+1 is not touched until level z is complete, so the fallback chain fills top-down and a budget stop can never leave a shallow level half-fetched while a deeper one streamed.
- Floored — the leading levels whose cumulative count fits 5 tiles (z0+z1 for a z0-rooted source) always complete, even at budget 0. They are the ancestor-walk backbone; the feature’s purpose survives the cap.
- Byte-budgeted — past the floor, once arrived bytes cross the budget (1.5 MiB under 900 px viewports, 4 MiB desktop — the original ~4 MB design intent made binding instead of assumed), the pump stops and unpins every never-arrived key so ordinary LRU can reclaim strays. Requests go out in 2-tile waves, so overshoot is bounded by one chunk.
- Backoff with a give-up — the tick doubles 250 ms → 8 s while nothing arrives, and after 6 zero-progress ticks it unpins the stragglers and stops. The design comment says it plainly: a dead source must not keep the radio warm.
The budget counts arrived bytes rather than requested ones, and viewport-driven fetches of the same keys count toward it — the skeleton doesn’t get to bill twice for a tile the camera wanted anyway.
How we know
Five unit gates, each verified failing before the fix: the budget stop, the
floor guarantee, the level staging, the give-up unpinning, and stop().
And the number that matters, same camera, same style, headless probe with
the relay harness: 91 tiles / 35.33 MB before → 16 tiles / 6.27 MB after
— an 82% cut, with the fallback backbone (z0+z1) still guaranteed complete.
What generalizes
Two lessons, both of which I’d have nodded past before this session.
First: budget an availability feature in the unit it spends, not the unit it’s designed in. “Depth 3” sounded like a policy about levels; it was actually a promise about bytes, made on a 50 KB assumption the real world broke by 8×. The fix didn’t remove the promise — it made the byte budget the binding constraint and let depth be the aspiration.
Second: when an investigation’s first finding is dramatic, check the instrument before the code. Our probe’s “93/93 duplicates” was wrong in a way that flattered the existing theory — double-counted evidence for the suspect we already had in custody. The CDP cross-count cost five minutes and saved a fix to a bug that didn’t exist. The real bug was quieter, 92% of the traffic, and pinned so it couldn’t even be evicted — found only after the numbers deserved trust.
References
- “The boundary audit missed an edge because a regex ate a digit” — the sibling incident: an instrument artifact presenting as a finding.
- “The hemisphere that wasn’t there” — the same skeleton’s fallback machinery on its best day, drawing the coarse parents this pyramid exists to provide.
- Session record:
docs/research/2026-07-13-globe-webgl2-bundle.md— includes the cross-instrumentation method note (“cross-instrument network claims (page.route AND CDP) before believing counts”).
Read next
rendering
7 min read
The hemisphere that was never selected: a fast-path bbox that collapsed to a point
Half the globe rendered as background again — same symptom as the drape-suppression bug, opposite root cause. This time the trans-antimeridian tiles were never selected: the overzoom fast-path unprojects the viewport corners to a lon/lat box, but on a small globe disc the corners miss the sphere, the box collapses to the sub-camera point, and it emits the single tile column under the camera — which a contiguous longitude range can never wrap across the dateline. Told apart from the draw bug by seam location and persistence.
verification
5 min read
Verified only where the bug showed: a hot-path fix that shipped a zoom-in regression
A WebGL2 flicker fix (#1139) was render-parity-verified on exactly one scenario — the zoom-OUT hold-tiles case that reproduced the original flicker, retained-content 0.19 → 0.79 matching WebGPU — and never on zoom-in or per-frame cost. It shipped an unmemoized per-frame classifyTile run twice per tile plus synchronous uploads, and stuttered with progressively-black fills on a Seoul zoom-in. Reverted 36 minutes later (#1140). A hot-path fix must be verified in its worst regime, not the one that showed the original bug.
rendering
7 min read
Draped at the wrong tile: a deferred write turned a shared uniform pool into a data race
A draped landcover polygon rendered hundreds of kilometres offshore — but only on WebGPU. Every vector slice of one source shares one Material uniform pool, and each slice resets the pool cursor to 0, so a later slice's tile i overwrote an earlier slice's tile-i buffer. WebGPU's queue.writeBuffer defers to submit (last-writer-wins), so every draw read the final bytes; WebGL2's immediate bufferSubData never showed it — and the differential test was structurally blind, because both frames aliased identically.