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:
- Refreshing an already-mapped area on a 14-day minimum cadence (rate limiting).
- 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:
| Threshold | Days | Behavior |
|---|---|---|
| Fresh | 0โ14 | Return cached venues, no API call |
| Minimum refresh interval | 14 | Won't refresh more often than this |
| Moderately stale | 30โ90 | Return cached + background refresh |
| Very stale | 90+ | 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 โ returnStep 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 dataStep 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) โ
- User grants location permission โ device gets lat/lng.
- App calls
getNearbyVenues(lat, lng). - Query hits Firestore using geohash bounds (geofire).
- Venues returned from Firestore cache.
- No Maps API call. User sees venues instantly.
Cost: $0 (Firestore reads only).
Scenario 2: User opens app in an unmapped area โ
- User grants location โ device gets lat/lng.
- App calls
getNearbyVenues(lat, lng). - Query hits Firestore โ no venues found for this geohash area.
- 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).
- If all checks pass โ trigger Maps API to fetch venues for this area.
- Venues written to Firestore with
lastRefreshedAttimestamp. - 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) โ
- Background job or user request triggers refresh check.
- Check
lastRefreshedAtfor the area's geohash prefix. - If older than TTL (30 days):
- Call Maps API to fetch fresh venues.
- Upsert venues into Firestore.
- Update
lastRefreshedAt.
- 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 โ
- User sends fake coordinates (e.g., claims to be in Tokyo from NYC).
- 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.
- No Maps API call triggered.
- User gets error or sees no venues.
Cost: $0 (blocked).
Scenario 5: User moves within the same area (e.g., across San Diego) โ
- User moves 2 km within SD.
- App calls
getNearbyVenues(newLat, newLng). - Still within same geohash-5 bucket โ Firestore query returns cached venues.
- No Maps API call.
Cost: $0.
Scenario 6: User travels to a new mapped area (e.g., SD โ LA) โ
- User opens app in LA.
- App calls
getNearbyVenues(lat, lng). - Query hits Firestore for LA geohash โ venues found (LA was already mapped).
- 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) โ
- User opens app in Phoenix.
- Query hits Firestore โ no venues for Phoenix geohash.
- Proximity gate validates user is actually in Phoenix.
- If allowed region + rate limit OK โ trigger Maps API for Phoenix.
- Venues written to Firestore.
- Future Phoenix users get cached data.
Cost: 1 Maps API call (one-time for Phoenix).
Summary Table โ
| Scenario | Maps API Call? | Who Pays? |
|---|---|---|
| Mapped area, normal use | No | โ |
| Unmapped area, first user | Yes (1x) | Shared |
| TTL refresh (30 days) | Yes (1x) | Shared |
| Spoofed/blocked location | No | โ |
| Move within same area | No | โ |
| Travel to mapped city | No | โ |
Configuration โ
TTL & Staleness Thresholds โ
Defined in src/lib/venueRefreshService.js:
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 refreshProximity Gating โ
Defined in src/lib/locationProximityGate.js:
// 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 * 1000Geohash Bucketing โ
We use geohash-5 prefixes (~4.9 km x 4.9 km) to group venues by area:
| Geohash Length | Approx Size | Use Case |
|---|---|---|
| 4 | ~39 km | Region/metro |
| 5 | ~4.9 km | City district (recommended) |
| 6 | ~1.2 km | Neighborhood |
| 7 | ~150 m | Block |
Geohash-5 balances:
- Few enough buckets to minimize fragmentation.
- Small enough to localize venue queries.
Cost Estimates โ
Google Places API Pricing โ
| API | Cost per 1,000 calls |
|---|---|
| Place Details | $17 |
| Nearby Search | $32 |
| Text Search | $32 |
| Autocomplete | $2.83 |
What Costs Money (API Calls) โ
| Event | Frequency | Cost |
|---|---|---|
| First user in new geohash-5 area | Once ever | ~$0.017โ0.032 |
| TTL refresh of existing area | Once 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 Type | Calculation | Monthly 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 Type | Calculation | Monthly Cost |
|---|---|---|
| TTL refreshes | 500 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:
- Don't pre-fetch data speculatively
- Refresh data that users request after 30 days
- Don't resell the data
Our 30-day TTL aligns with this requirement.
Related Files โ
| File | Role |
|---|---|
apps/web/src/lib/venueService.js | Venue CRUD, getNearbyVenues(), enrichment queue, subscribeToVenueUpdates() |
apps/web/src/lib/venueRefreshService.js | Geohash staleness tracking, triggerAreaRefresh() |
apps/web/src/lib/venueApiClient.js | HTTP client for venue-api Cloud Run service |
apps/web/src/lib/venueCacheManager.js | localStorage venue cache (TTL + location validation) |
apps/web/src/lib/locationProximityGate.js | Proximity validation & rate limiting |
apps/web/src/screens/dashboard/Dashboard.jsx | Orchestrates the full loading pipeline |
services/api/venues/ | Cloud Run venue-api (import, enrichment, admin) |
services/api/venues/src/services/venue.service.js | Server-side Firestore writes |
services/api/venues/src/routes/import.js | OSM import endpoint |
services/api/venues/src/routes/refresh.js | Enrichment 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 โ
| File | Purpose |
|---|---|
apps/web/src/lib/venueService.js | prefetchVenueAddresses(), enrichVenueAddress(), batch queue |
apps/web/src/lib/venueApiClient.js | HTTP client for venue-api (enrichVenue(), batchRefreshVenues()) |
services/api/venues/src/routes/refresh.js | Server-side enrichment endpoints |
services/api/venues/src/services/nominatim.service.js | Nominatim reverse geocoding |
services/api/venues/src/services/venue.service.js | Firestore writes (server-side) |
Session-Level Deduplication โ
Three guards prevent redundant enrichment calls:
enrichedThisSession(Set) โ venues already enriched this browser sessionenrichmentInProgress(Set) โ venues currently being enriched (prevents concurrent calls)prefetchQueue.pending(Map) โ venues queued but not yet processed
Monitoring โ
In development, enrichment timing stats are available:
import { getEnrichmentTimings } from './lib/venueService'
console.log(getEnrichmentTimings())
// { avgCloudFunctionMs: 450, avgTotalMs: 520, count: 12, batchCount: 10, singleCount: 2, history: [...] }See Also โ
- LOCATION_STACK.md - Full location architecture
- DATABASE_SCALING.md - Firestore cost considerations
- Issue #210 - Location caching implementation
- Issue #155 - Server-side validation
Caching Layers Summary โ
The venue system uses four distinct caching layers, each with different TTLs and scopes:
| Layer | Location | TTL | Scope | Purpose |
|---|---|---|---|---|
| localStorage cache | Browser localStorage | 5 min + location drift | Per-device | Show venues before geolocation resolves |
| In-memory query cache | venueQueryCache Map | 2 min | Per-session, per-tab | Avoid redundant Firestore queries within a session |
| Firestore venues | venues collection | Permanent (refreshed by import) | Global (shared) | Source of truth for all venue data |
| Geohash refresh metadata | venueRefreshMetadata collection | 14โ90 day thresholds | Global (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 clearedKnown Cache Edge Cases โ
- Latitude 0 (equator):
isCacheValidForLocationguards withif (currentLat && currentLng)which treats0as falsy, skipping the location check for venues at the equator. invalidateVenueCachedoesn'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.- In-progress lock race: Two tabs can both read
inProgress=false, both writetrue, 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 })