Problem (measured 2026-06-25, prod with #4425 live)
Prod mobile Lighthouse ×3 on /dashboard: Perf median 67, TBT ~1691 ms, while LCP/FCP are already fine (~1.3 s). The gap is main-thread JS, not paint: scriptEvaluation 3705 ms ≫ styleLayout 1320 ms.
Per-script boot scripting ranking (the real lever, not byte size):
| Chunk |
Boot script ms |
Map-*.js |
1277 🔴 #1 |
main.js |
1213 |
panel-gating |
447 |
panels-* (feeds catalog — the abandoned #4410 target) |
49 ⚪ |
Root cause
src/components/MapContainer.ts selects the renderer by device — DeckGLMap (WebGL) on desktop, but the 4,194-line D3/SVG MapComponent (src/components/Map.ts) on mobile (shouldUseDeckGL() returns false for isMobileDevice()). On mobile, MapComponent's boot path (constructor → loadMapData() → topojson.feature() → render()) builds the base map and all dynamic overlays synchronously: renderCables/renderPipelines/renderConflicts/renderAisDensity (per layer toggle) + always-on renderClusterLayer + renderOverlays, all emitted as SVG DOM. That one component owns the 1277 ms scriptEval and much of the 1320 ms styleLayout + the ~33K-element DOM. The "lightweight SVG renderer" assumption is falsified by measurement.
(The big vendors — maplibre/deck/GlobeMap — are lazy chunks not loaded on mobile; they are not the problem.)
Lever
Defer the dynamic-overlay portion of the first render off the first-paint critical path via the existing scheduleAfterFirstPaint (src/bootstrap/secondary-startup.ts:11). render() already separates a cached base layer (background/grid/graticule/countries — LCP-critical, keep synchronous) from a per-call dynamic layer (lines ~1158–1191 — defer). Same "paint critical content, schedule the rest" approach as #4367.
Plan (durable copy; docs/plans is gitignored)
U1 — Instrument the base-vs-dynamic render split (measure-first). Confirm the dynamic block dominates the 1277 ms before deferring it; if topojson+countries (base) dominates instead, re-scope to a base-geometry follow-up.
U2 — Defer the first dynamic-overlay render. Add a one-time initialDynamicRendered flag; after the base block in render(), if not yet rendered, scheduleAfterFirstPaint(() => { this.initialDynamicRendered = true; this.render(); }) and return before the dynamic block. All post-first-paint renders (zoom/pan/toggle/theme) stay synchronous. New test tests/map-deferred-overlays.test.mts.
U3 — Verify + re-measure. Mobile Lighthouse ×3 vs the 1277 ms / TBT ~1691 ms baseline; verify all layers render post-first-paint + interactions immediate on a real mobile viewport; remove instrumentation.
Scope / risk
- Mobile/fallback SVG
MapComponent only; desktop DeckGLMap/GlobeMap unchanged. No chunk-graph change (runtime scheduling only).
- Base render (topojson + countries) stays synchronous (LCP). Base-geometry optimization is a follow-up if U1 shows it dominates.
- Blast radius is mobile-wide (this is the mobile primary renderer) → verify on a real mobile viewport, not just unit tests. Guard the flags so overlays can't get stuck unrendered (
scheduleAfterFirstPaint has a 3 s timeout fallback; next interaction render recovers).
Why this over the alternatives
Build-verified this session: #4410 (feeds/bases/proto deferral) is ~49 ms each — noise. #4425 already banked the big eager-JS win (zod/satellite/confetti, −29.5 KB brotli). This map deferral targets the actual #1 mobile TBT cost.
Related: #4367 (defer below-fold panel DOM), #3997 (sibling DOM/styleLayout lever — ConsumerPrices tbody), #4382 (SVG-fallback regression context), #3987 (homepage LCP parent).
Problem (measured 2026-06-25, prod with #4425 live)
Prod mobile Lighthouse ×3 on
/dashboard: Perf median 67, TBT ~1691 ms, while LCP/FCP are already fine (~1.3 s). The gap is main-thread JS, not paint:scriptEvaluation3705 ms ≫styleLayout1320 ms.Per-script boot scripting ranking (the real lever, not byte size):
Map-*.jsmain.jspanel-gatingpanels-*(feeds catalog — the abandoned #4410 target)Root cause
src/components/MapContainer.tsselects the renderer by device — DeckGLMap (WebGL) on desktop, but the 4,194-line D3/SVGMapComponent(src/components/Map.ts) on mobile (shouldUseDeckGL()returnsfalseforisMobileDevice()). On mobile,MapComponent's boot path (constructor→loadMapData()→topojson.feature()→render()) builds the base map and all dynamic overlays synchronously:renderCables/renderPipelines/renderConflicts/renderAisDensity(per layer toggle) + always-onrenderClusterLayer+renderOverlays, all emitted as SVG DOM. That one component owns the 1277 ms scriptEval and much of the 1320 ms styleLayout + the ~33K-element DOM. The "lightweight SVG renderer" assumption is falsified by measurement.(The big vendors — maplibre/deck/GlobeMap — are lazy chunks not loaded on mobile; they are not the problem.)
Lever
Defer the dynamic-overlay portion of the first render off the first-paint critical path via the existing
scheduleAfterFirstPaint(src/bootstrap/secondary-startup.ts:11).render()already separates a cached base layer (background/grid/graticule/countries — LCP-critical, keep synchronous) from a per-call dynamic layer (lines ~1158–1191 — defer). Same "paint critical content, schedule the rest" approach as #4367.Plan (durable copy;
docs/plansis gitignored)U1 — Instrument the base-vs-dynamic render split (measure-first). Confirm the dynamic block dominates the 1277 ms before deferring it; if topojson+countries (base) dominates instead, re-scope to a base-geometry follow-up.
U2 — Defer the first dynamic-overlay render. Add a one-time
initialDynamicRenderedflag; after the base block inrender(), if not yet rendered,scheduleAfterFirstPaint(() => { this.initialDynamicRendered = true; this.render(); })and return before the dynamic block. All post-first-paint renders (zoom/pan/toggle/theme) stay synchronous. New testtests/map-deferred-overlays.test.mts.U3 — Verify + re-measure. Mobile Lighthouse ×3 vs the 1277 ms / TBT ~1691 ms baseline; verify all layers render post-first-paint + interactions immediate on a real mobile viewport; remove instrumentation.
Scope / risk
MapComponentonly; desktopDeckGLMap/GlobeMapunchanged. No chunk-graph change (runtime scheduling only).scheduleAfterFirstPainthas a 3 s timeout fallback; next interaction render recovers).Why this over the alternatives
Build-verified this session: #4410 (feeds/bases/proto deferral) is ~49 ms each — noise. #4425 already banked the big eager-JS win (zod/satellite/confetti, −29.5 KB brotli). This map deferral targets the actual #1 mobile TBT cost.
Related: #4367 (defer below-fold panel DOM), #3997 (sibling DOM/styleLayout lever — ConsumerPrices tbody), #4382 (SVG-fallback regression context), #3987 (homepage LCP parent).