Skip to content

perf: defer mobile SVG-map dynamic overlays off the boot path (Map-*.js = 1277ms, #1 mobile TBT) #4429

Description

@koala73

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 (constructorloadMapData()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).

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