Skip to content

Venue Caching & API Cost Control โ€‹

Status: Active
Updated: 2026-03-16
Related Issues: #210, #155, #249, #224, #394

Overview โ€‹

Venues are cached in Firestore and shared by all users. The Maps API (or other external venue source) is called only when:

  1. Refreshing an already-mapped area on a 14-day minimum cadence (rate limiting).
  2. First-time mapping of a new/unmapped area.

This keeps paid API costs low while ensuring users always have venue data.


Geohash-Based Caching โ€‹

Each geographic area is identified by a 5-character geohash prefix (~5km ร— 5km cell):

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚                    GEOHASH PRECISION GUIDE                      โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ Precision โ”‚ Cell Size        โ”‚ Use Case                         โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ 4         โ”‚ ~39km ร— 19.5km   โ”‚ Metro area (too coarse)          โ”‚
โ”‚ 5         โ”‚ ~4.9km ร— 4.9km   โ”‚ โœ… Neighborhood (current)        โ”‚
โ”‚ 6         โ”‚ ~1.2km ร— 0.6km   โ”‚ City blocks (too granular)       โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Why precision 5?

  • Matches typical user search radius (~5km)
  • Balances API cost (fewer cells = fewer refreshes)
  • Still granular enough for urban areas

Refresh Thresholds โ€‹

Each geohash area has independent refresh metadata:

ThresholdDaysBehavior
Fresh0โ€“14Return cached venues, no API call
Minimum refresh interval14Won't refresh more often than this
Moderately stale30โ€“90Return cached + background refresh
Very stale90+Block and wait for fresh API data
Never importedโˆžBlock โ€” treated same as very stale

Configuration: src/lib/venueRefreshService.js


Architecture Diagram โ€‹

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚                            FIRESTORE                                     โ”‚
โ”‚  venues collection (shared by all users)                                 โ”‚
โ”‚  - Keyed by area (geohash), contains name/lat/lng/category/etc.          โ”‚
โ”‚                                                                          โ”‚
โ”‚  venueRefreshMetadata collection                                         โ”‚
โ”‚  - Tracks lastRefreshedAt per 5-char geohash prefix                      โ”‚
โ”‚  - inProgress lock (5-min timeout) prevents concurrent refreshes         โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ–ฒโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
       โ”‚ write (server-side only)          โ”‚ read (geohash range queries)
       โ”‚                                   โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”          โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  Venues API         โ”‚          โ”‚  Client (Dashboard)                    โ”‚
โ”‚  (Cloud Run)        โ”‚          โ”‚                                       โ”‚
โ”‚  POST /import/osm   โ”‚โ—„โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”‚  1. loadInitialVenues()               โ”‚
โ”‚  POST /refresh/batchโ”‚          โ”‚  2. getNearbyVenues(lat,lng,1km)      โ”‚
โ”‚  POST /refresh/     โ”‚          โ”‚  3. prefetchVenueAddresses(venues)    โ”‚
โ”‚    enrich/:id       โ”‚          โ”‚  4. subscribeToVenueUpdates(ids)      โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜          โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
       โ”‚                                   โ–ฒ
       โ–ผ                                   โ”‚ (localStorage)
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”          โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  OSM Overpass API    โ”‚          โ”‚  venueCacheManager                  โ”‚
โ”‚  (venue import)      โ”‚          โ”‚  - 5-min TTL + Haversine location   โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค          โ”‚  - Persisted to localStorage         โ”‚
โ”‚  Nominatim           โ”‚          โ”‚  - Loaded before geolocation resolvesโ”‚
โ”‚  (address enrichment)โ”‚          โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Full Dashboard Loading Pipeline โ€‹

This traces every step from app open to venues on screen.

Step 1: Pre-Geolocation (immediate) โ€‹

Dashboard mounts
 โ””โ”€ loadInitialVenues()
     โ”œโ”€ setLoadingVenues(true)
     โ””โ”€ if cachedVenues && isCacheValid(cachedVenues)  // time-only check, 5min TTL
         โ”œโ”€ setVenues(cached.venues)     โ† user sees venues NOW
         โ”œโ”€ setLoadingVenues(false)      โ† spinner gone
         โ””โ”€ (geolocation request starts in parallel)

Step 2: Geolocation Resolves โ€‹

getLocation callback fires with lat/lng
 โ”œโ”€ setUserLocation({lat, lng})
 โ””โ”€ isCacheValidForLocation(cache, lat, lng)?  // time + Haversine distance
     โ”‚
     โ”œโ”€โ”€ YES (cache hit) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
     โ”‚   โ”œโ”€ setLoadingVenues(false)   โ”‚
     โ”‚   โ”œโ”€ subscribeToVenueUpdates   โ”‚  skipInitialSnapshot=false
     โ”‚   โ”‚   (corrects stale counts)  โ”‚  because data is from cache
     โ”‚   โ””โ”€ return                    โ”‚
     โ”‚                                โ”‚
     โ””โ”€โ”€ NO (cache miss / moved) โ”€โ”€โ”€โ”€โ”€โ”ค
         โ””โ”€ getNearbyVenues(lat, lng, 1km, {limit: 20})

Step 3: getNearbyVenues โ€” Staleness Check โ€‹

getNearbyVenues(lat, lng, radius)
 โ”œโ”€ Compute geohash query bounds โ†’ touched prefixes (1โ€“4 cells)
 โ”œโ”€ checkStaleness(prefix) for each cell โ†’ partition into:
 โ”‚   โ”œโ”€ freshCells      (< 30 days)   โ†’ skip
 โ”‚   โ”œโ”€ backgroundCells (30โ€“90 days)  โ†’ non-blocking refresh
 โ”‚   โ””โ”€ blockingCells   (> 90 days or never imported) โ†’ WAIT
 โ”‚
 โ”œโ”€ BLOCKING CELLS present?
 โ”‚   โ”œโ”€ markRefreshInProgress(prefix) for each
 โ”‚   โ”œโ”€ triggerAreaRefresh(prefix, lat, lng, radius)
 โ”‚   โ”‚   โ””โ”€ venue-api POST /venues/import/osm
 โ”‚   โ”‚       โ”œโ”€ Overpass API โ†’ fetch OSM venues
 โ”‚   โ”‚       โ”œโ”€ importVenuesToFirestore() โ†’ dedup + write
 โ”‚   โ”‚       โ””โ”€ response: { imported, skipped, importedVenues[] }
 โ”‚   โ”‚
 โ”‚   โ””โ”€ SHORTCUT: if skipped=0 && importedVenues.length > 0
 โ”‚       โ”œโ”€ Filter/sort imported venues client-side
 โ”‚       โ”œโ”€ Seed in-memory cache
 โ”‚       โ””โ”€ RETURN immediately (no Firestore query needed)
 โ”‚
 โ”œโ”€ BACKGROUND CELLS present?
 โ”‚   โ””โ”€ triggerAreaRefresh() fire-and-forget
 โ”‚
 โ”œโ”€ Check in-memory cache (2min TTL, skip if any cell was stale)
 โ”‚
 โ””โ”€ Firestore geohash range queries โ†’ filter/sort โ†’ return

Step 4: Display + Subscriptions โ€‹

Venues returned to Dashboard
 โ”œโ”€ setVenues(formattedVenues)
 โ”œโ”€ setLoadingVenues(false)
 โ”œโ”€ prefetchVenueAddresses(venues)    โ† background enrichment via venue-api
 โ””โ”€ subscribeToVenueUpdates(ids)      โ† real-time lantern count updates
     โ””โ”€ onSnapshot with batched 'in' queries (30 per batch)
         โ””โ”€ skipInitialSnapshot=true for fresh data

Step 5: Load More (on-demand) โ€‹

User scrolls to bottom โ†’ loadMoreVenues()
 โ”œโ”€ getNearbyVenues(lat, lng, 5km)    โ† wider radius
 โ”œโ”€ Merge with existing venues (dedup by id)
 โ”œโ”€ Update subscribeToVenueUpdates with all venue IDs
 โ””โ”€ prefetchVenueAddresses(new venues)

Scenarios โ€‹

Scenario 1: User opens app in a mapped area (e.g., San Diego) โ€‹

  1. User grants location permission โ†’ device gets lat/lng.
  2. App calls getNearbyVenues(lat, lng).
  3. Query hits Firestore using geohash bounds (geofire).
  4. Venues returned from Firestore cache.
  5. No Maps API call. User sees venues instantly.

Cost: $0 (Firestore reads only).


Scenario 2: User opens app in an unmapped area โ€‹

  1. User grants location โ†’ device gets lat/lng.
  2. App calls getNearbyVenues(lat, lng).
  3. Query hits Firestore โ†’ no venues found for this geohash area.
  4. Proximity gate check:
    • Is user actually at this location? (server-side validation)
    • Is area allowed? (if you restrict to certain regions)
    • Rate limit check (prevent abuse).
  5. If all checks pass โ†’ trigger Maps API to fetch venues for this area.
  6. Venues written to Firestore with lastRefreshedAt timestamp.
  7. User sees venues.

Cost: 1 Maps API call (one-time for this area, shared by all future users).


Scenario 3: Scheduled refresh of a mapped area (TTL expired) โ€‹

  1. Background job or user request triggers refresh check.
  2. Check lastRefreshedAt for the area's geohash prefix.
  3. If older than TTL (30 days):
    • Call Maps API to fetch fresh venues.
    • Upsert venues into Firestore.
    • Update lastRefreshedAt.
  4. All users now get fresh data from Firestore.

Cost: 1 Maps API call per area per 30 days.


Scenario 4: User tries to spoof location to trigger new area mapping โ€‹

  1. User sends fake coordinates (e.g., claims to be in Tokyo from NYC).
  2. Proximity gate rejects:
    • Server-side validation compares claimed coords vs. IP geolocation (if implemented).
    • Or: area is not in allowed regions list.
    • Or: rate limit exceeded.
  3. No Maps API call triggered.
  4. User gets error or sees no venues.

Cost: $0 (blocked).


Scenario 5: User moves within the same area (e.g., across San Diego) โ€‹

  1. User moves 2 km within SD.
  2. App calls getNearbyVenues(newLat, newLng).
  3. Still within same geohash-5 bucket โ†’ Firestore query returns cached venues.
  4. No Maps API call.

Cost: $0.


Scenario 6: User travels to a new mapped area (e.g., SD โ†’ LA) โ€‹

  1. User opens app in LA.
  2. App calls getNearbyVenues(lat, lng).
  3. Query hits Firestore for LA geohash โ†’ venues found (LA was already mapped).
  4. No Maps API call.

Cost: $0.


Scenario 7: User travels to a new unmapped area (e.g., SD โ†’ Phoenix, if Phoenix isn't mapped yet) โ€‹

  1. User opens app in Phoenix.
  2. Query hits Firestore โ†’ no venues for Phoenix geohash.
  3. Proximity gate validates user is actually in Phoenix.
  4. If allowed region + rate limit OK โ†’ trigger Maps API for Phoenix.
  5. Venues written to Firestore.
  6. Future Phoenix users get cached data.

Cost: 1 Maps API call (one-time for Phoenix).


Summary Table โ€‹

ScenarioMaps API Call?Who Pays?
Mapped area, normal useNoโ€”
Unmapped area, first userYes (1x)Shared
TTL refresh (30 days)Yes (1x)Shared
Spoofed/blocked locationNoโ€”
Move within same areaNoโ€”
Travel to mapped cityNoโ€”

Configuration โ€‹

TTL & Staleness Thresholds โ€‹

Defined in src/lib/venueRefreshService.js:

javascript
export const MINIMUM_REFRESH_INTERVAL_DAYS = 14 // Won't refresh more often
export const MODERATE_STALENESS_DAYS = 30       // Background refresh
export const VERY_STALE_DAYS = 90               // Blocking refresh

Proximity Gating โ€‹

Defined in src/lib/locationProximityGate.js:

javascript
// Cache TTL for area metadata (30 days)
export const DEFAULT_PLACE_CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000

// Distance threshold for movement detection (500m)
export const DEFAULT_DISTANCE_THRESHOLD_METERS = 500

// Proximity gate radius for place operations (200m)
export const DEFAULT_PROXIMITY_GATE_RADIUS_METERS = 200

// Rate limit: max 10 API calls per minute
export const MAX_API_CALLS_PER_WINDOW = 10
export const RATE_LIMIT_WINDOW_MS = 60 * 1000

Geohash Bucketing โ€‹

We use geohash-5 prefixes (~4.9 km x 4.9 km) to group venues by area:

Geohash LengthApprox SizeUse Case
4~39 kmRegion/metro
5~4.9 kmCity district (recommended)
6~1.2 kmNeighborhood
7~150 mBlock

Geohash-5 balances:

  • Few enough buckets to minimize fragmentation.
  • Small enough to localize venue queries.

Cost Estimates โ€‹

Google Places API Pricing โ€‹

APICost per 1,000 calls
Place Details$17
Nearby Search$32
Text Search$32
Autocomplete$2.83

What Costs Money (API Calls) โ€‹

EventFrequencyCost
First user in new geohash-5 areaOnce ever~$0.017โ€“0.032
TTL refresh of existing areaOnce per 30 days per area~$0.017โ€“0.032

What's Free โ€‹

All users reading venues from Firestore = no Maps API cost. They're reading cached data.

Realistic Monthly Estimates โ€‹

Scenario: 10k users, mostly in San Diego metro (~50 geohash-5 areas)

Cost TypeCalculationMonthly Cost
TTL refreshes (30-day)50 areas ร— $0.032$1.60
New unmapped areas~10 new areas ร— $0.032$0.32
Total~$2/month

Scenario: 10k users spread across 500 areas nationwide

Cost TypeCalculationMonthly Cost
TTL refreshes500 areas ร— $0.032$16/month

Caching Compliance โ€‹

Google's Terms of Service allow caching Place data for up to 30 days as long as you:

  1. Don't pre-fetch data speculatively
  2. Refresh data that users request after 30 days
  3. Don't resell the data

Our 30-day TTL aligns with this requirement.


FileRole
apps/web/src/lib/venueService.jsVenue CRUD, getNearbyVenues(), enrichment queue, subscribeToVenueUpdates()
apps/web/src/lib/venueRefreshService.jsGeohash staleness tracking, triggerAreaRefresh()
apps/web/src/lib/venueApiClient.jsHTTP client for venue-api Cloud Run service
apps/web/src/lib/venueCacheManager.jslocalStorage venue cache (TTL + location validation)
apps/web/src/lib/locationProximityGate.jsProximity validation & rate limiting
apps/web/src/screens/dashboard/Dashboard.jsxOrchestrates the full loading pipeline
services/api/venues/Cloud Run venue-api (import, enrichment, admin)
services/api/venues/src/services/venue.service.jsServer-side Firestore writes
services/api/venues/src/routes/import.jsOSM import endpoint
services/api/venues/src/routes/refresh.jsEnrichment endpoints (single + batch)

Address Enrichment (via Venues API) โ€‹

Overview โ€‹

Venues imported from OSM have placeholder or missing addresses (just coordinates). We use the Venues API (Cloud Run service at services/api/venues/) which calls Nominatim server-side to enrich venue addresses. This replaces the old client-side Nominatim calls and Firebase Cloud Function approach.

Enrichment Pipeline โ€‹

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ Venues load โ†’ prefetchVenueAddresses(venues)                     โ”‚
โ”‚                                                                  โ”‚
โ”‚ For each venue missing addressComponents:                        โ”‚
โ”‚   โ†’ Queue in prefetchQueue (Map<venueId, {id, lat, lng}>)        โ”‚
โ”‚   โ†’ Process up to 15 venues per batch                            โ”‚
โ”‚   โ†’ POST /venues/refresh/batch โ†’ Venues API (Cloud Run)          โ”‚
โ”‚       โ””โ†’ Nominatim reverse geocode (server-side, sequential)     โ”‚
โ”‚       โ””โ†’ Update Firestore addressComponents                      โ”‚
โ”‚   โ†’ onSnapshot delivers update to subscribed clients             โ”‚
โ”‚                                                                  โ”‚
โ”‚ On-demand: User opens venue detail                               โ”‚
โ”‚   โ†’ enrichVenueAddress(venueId, venueMeta)                       โ”‚
โ”‚   โ†’ POST /venues/refresh/enrich/:venueId โ†’ Venues API           โ”‚
โ”‚   โ†’ Client applies result optimistically + onSnapshot confirms   โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Key Files โ€‹

FilePurpose
apps/web/src/lib/venueService.jsprefetchVenueAddresses(), enrichVenueAddress(), batch queue
apps/web/src/lib/venueApiClient.jsHTTP client for venue-api (enrichVenue(), batchRefreshVenues())
services/api/venues/src/routes/refresh.jsServer-side enrichment endpoints
services/api/venues/src/services/nominatim.service.jsNominatim reverse geocoding
services/api/venues/src/services/venue.service.jsFirestore writes (server-side)

Session-Level Deduplication โ€‹

Three guards prevent redundant enrichment calls:

  1. enrichedThisSession (Set) โ€” venues already enriched this browser session
  2. enrichmentInProgress (Set) โ€” venues currently being enriched (prevents concurrent calls)
  3. prefetchQueue.pending (Map) โ€” venues queued but not yet processed

Monitoring โ€‹

In development, enrichment timing stats are available:

javascript
import { getEnrichmentTimings } from './lib/venueService'
console.log(getEnrichmentTimings())
// { avgCloudFunctionMs: 450, avgTotalMs: 520, count: 12, batchCount: 10, singleCount: 2, history: [...] }

See Also โ€‹


Caching Layers Summary โ€‹

The venue system uses four distinct caching layers, each with different TTLs and scopes:

LayerLocationTTLScopePurpose
localStorage cacheBrowser localStorage5 min + location driftPer-deviceShow venues before geolocation resolves
In-memory query cachevenueQueryCache Map2 minPer-session, per-tabAvoid redundant Firestore queries within a session
Firestore venuesvenues collectionPermanent (refreshed by import)Global (shared)Source of truth for all venue data
Geohash refresh metadatavenueRefreshMetadata collection14โ€“90 day thresholdsGlobal (shared)Determines when to re-import from OSM

Cache Invalidation Flow โ€‹

App.jsx unmounts Dashboard
 โ””โ”€ setCachedVenues(null)        โ† only clears React state
    โš ๏ธ Does NOT call clearPersistedVenueCache()
    โš ๏ธ localStorage keeps the old cache (restored on remount)

invalidateVenueCache(setCachedVenues)
 โ””โ”€ setCachedVenues(null)        โ† same gap: localStorage not cleared

Known Cache Edge Cases โ€‹

  1. Latitude 0 (equator): isCacheValidForLocation guards with if (currentLat && currentLng) which treats 0 as falsy, skipping the location check for venues at the equator.
  2. invalidateVenueCache doesn't clear localStorage: Only clears React state. The old cache survives and gets restored on next mount. Low risk since TTL catches it, but can cause confusion during debugging.
  3. In-progress lock race: Two tabs can both read inProgress=false, both write true, and both trigger the Venues API. The 5-min timeout mitigates this, and the server-side dedup prevents duplicate venues, but it wastes an API call.

Real-Time Updates (subscribeToVenueUpdates) โ€‹

Venues subscribe to Firestore onSnapshot listeners for real-time lantern count changes. The implementation uses batched where(documentId(), 'in', ids) queries (max 30 per batch) instead of individual document listeners.

skipInitialSnapshot option:

  • true (default): The first snapshot from each batch is ignored. Use when the caller just fetched fresh data from Firestore.
  • false: The first snapshot is processed. Use when the caller's data is from cache (localStorage) and may have stale lantern counts.
subscribeToVenueUpdates(venueIds, onUpdate, { skipInitialSnapshot: false })

Built with VitePress