The Problem
Existing public fuel price portals for Spain and France are slow, ad-saturated, and mobile-hostile. Official government APIs exist but require normalization, caching strategy, and a geo layer to be usable.
The engineering challenge: serve 11,000+ stations across two countries under concurrent user load, with graceful degradation when upstream government APIs fail — without blocking the UI.
Architecture Decisions
Viewport-based loading
No global data dump on load. The backend resolves which regions fall within the map viewport and loads only those — bounded by a thread pool with Redis locks to prevent concurrent duplicate warming.
Redis resilience
Primary cache + 7-day backup per region. If the upstream API fails mid-load, the backend serves stale backup data. Failure cooldowns prevent hammering broken endpoints.
Frontend
Next.js 16 + React 19 with next-intl for ES/EN/FR. MapLibre GL for vector tile rendering. Supercluster for client-side marker clustering at scale. Zustand for UI state, Framer Motion for micro-interactions. Playwright E2E tests for map and search flows.
Trade-offs Made
MapLibre GL over Leaflet
Vector tiles + WebGL rendering gives smooth 60fps pan/zoom at any density. Leaflet SVG DOM couldn't handle 11k+ markers on mobile without degrading. MapLibre was the right tool.
Multi-country via factory pattern
Each country (ES, FR, DE in progress) has its own service implementation behind a FuelServiceFactory. Different governments, different APIs, different geo sources — abstracted cleanly.
Metrics that matter
2+
countries live
<50ms
viewport API p99
ViewportResolver.java
@Service
public class ViewportResolver {
public ViewportResult resolve(Viewport vp, String country, String fuel) {
List<String> regions = regionMapper.intersecting(vp, country);
List<String> missing = cacheService.filterUncached(regions);
if (!missing.isEmpty()) {
executor.scheduleBounded(missing, country);
}
List<StandardStation> stations = cacheService.getOrBackup(regions, fuel);
boolean complete = missing.isEmpty();
return new ViewportResult(stations, regions, missing, complete);
}
}