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 callGET /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:
| Budget | Target | Current |
|---|---|---|
Critical-path eager JS (index.js) | ≤ 160 KB gzip | 148 KB |
| Multi-Country page chunk | ≤ 50 KB gzip | 42 KB |
| Multi-Country cold-visit blocking round-trips | ≤ 6 | 5 |
| Period switches on Multi-Country (3D ↔ 7D ↔ MONTH) | 0 network requests | 0 (client-side filter) |
<link rel="modulepreload"> for async chunks | none in prod index.html | 0 |
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
frontend/src/pages/MultiCountryPage.jsx— uses the batch hooksfrontend/src/hooks/useApi.js—useMarketPricesBatch,useForecastCombinedBatchfrontend/vite.config.js—modulePreload.resolveDependenciesoverridesrc/api/routes.py—/forecast/combined/batchand/market/prices/batch
Related
- Cloudflare CDN — companion edge-layer change shipped the same week
- Multi-Country v2.0 — the page this sprint primarily optimized