Skip to content

Performance Sprint — Lazy Loading + Batch Endpoints

Date: May 5-6, 2026 | Status: ✅ Production

Why this version exists

The Multi-Country page had become the worst-performing surface on the site — every page load triggered four parallel forecast fetches (one per country) plus four market-price fetches plus a map JSON, on top of the recharts vendor bundle being eagerly loaded onto the critical path of every page that mounted a chart-importing component. EU users on residential connections were waiting 4-6 seconds for first paint.

The sprint targeted three layers: critical-path JS size, HTTP request count, and request serialization on the API side.

What changed

Frontend — lazy-loading any component that imports recharts

The CLAUDE.md rule “lazy-load any component that imports recharts” was enforced more aggressively. MonthYearPicker, InfoPanel, and the per-page chart components are now lazy()-imported with <Suspense fallback={<SkeletonChart />}> boundaries. The recharts vendor chunk (114 KB gzip) no longer rides the critical path of pages that don’t actually render a chart on first paint.

vite.config.js got build.modulePreload.resolveDependencies: () => [] to suppress the automatic <link rel="modulepreload"> injection for async chunks. Without that, Vite would helpfully preload all the lazy chunks and undo the lazy-loading benefit. The trade-off (lazy chunks load on demand rather than during idle time) was deliberate — for the access patterns this app actually sees, demand-loading is faster.

Backend — batch endpoints

Two new endpoints collapse the per-country fan-out on the Multi-Country page:

  • GET /api/v1/forecast/combined/batch?countries=ES,PT,FR,DE&days_back=N — returns {country: response, ...} in one HTTP call
  • GET /api/v1/market/prices/batch?countries=ES,PT,FR,DE — same pattern for market prices

Both are thin loops over the existing per-country handlers. Caching is unchanged (one cache entry per country per approach), so the batch endpoints don’t add any cache-eviction risk.

The reason this matters is API serialization: production runs on 2 uvicorn workers, so four parallel single-country requests get partly serialized at the worker level — first ES, then PT/FR/DE in a staggered cascade. One batch call inside a single worker iterates the four countries from the same cached state without any worker contention.

Forecast payload trim

/api/v1/forecast/combined?days_back=N slices the cached 90-day response per request. The Multi-Country page now passes days_back=32 (covers up to MONTH view) so the 3D ↔ 7D ↔ MONTH switches are pure client-side dateRange filters with zero refetch. YEAR view passes null for the full window. HomePage still uses the full window for its historical comparison. Cache key stays one-per-(country, approach) — trim happens after _fc_get.

Performance budget gates

The sprint also wrote down explicit performance budgets in CLAUDE.md so any future regression triggers a discussion:

BudgetTargetCurrent
Critical-path eager JS (index.js)≤ 160 KB gzip148 KB
Multi-Country page chunk≤ 50 KB gzip42 KB
Multi-Country cold-visit blocking round-trips≤ 65
Period switches on Multi-Country (3D ↔ 7D ↔ MONTH)0 network requests0 (client-side filter)
<link rel="modulepreload"> for async chunksnone in prod index.html0

VM right-sizing follow-up

A separate revert-and-restore happened during the sprint: the VM API workers had been temporarily scaled from 4 → 2 workers, which caused intermittent swap-thrashing on cold-cache requests. The 2-worker config was restored after the swap-thrash was diagnosed; the staggered “ES first then PT/FR/DE” cascade described above is partly a consequence of the 2-worker config and partly a consequence of per-country fan-out — the batch endpoints address the second factor.

Key files