Venue Caching & API Cost Control
Status: Active
Updated: 2026-02-01
Related Issues: #210, #155, #249
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 | Rate limit - won't refresh more often than this |
| Moderately stale | 14-30 | Return cached + background refresh |
| Very stale | 30+ | Block and wait for fresh API data |
Configuration: src/lib/venueConfig.js → VENUE_REFRESH_THRESHOLDS
Architecture Diagram
┌─────────────────────────────────────────────────────────────────┐
│ FIRESTORE │
│ venues collection (shared by all users) │
│ - Keyed by area (geohash) │
│ - Contains: name, lat, lng, geohash, category, placeId, etc. │
│ │
│ venueRefreshMetadata collection │
│ - Tracks lastRefreshedAt per geohash prefix (area) │
│ - Used to determine when to refresh from external API │
└─────────────────────────────────────────────────────────────────┘
▲ │
│ (one-time or 30-day refresh) │ (every user query)
│ ▼
┌───────────────────┐ ┌───────────────────────┐
│ MAPS API │ │ USER DEVICE │
│ (paid, external) │ │ - getNearbyVenues() │
│ │ │ - geofire query │
└───────────────────┘ └───────────────────────┘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:
// Background refresh (non-blocking) after 30 days
export const MODERATE_STALENESS_DAYS = 30
// Blocking refresh (wait for data) after 90 days
export const VERY_STALE_DAYS = 90Proximity 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
- src/lib/venueRefreshService.js - Area staleness tracking
- src/lib/locationProximityGate.js - Proximity validation & rate limiting
- src/lib/venueService.js - Venue CRUD & nearby queries
- src/lib/placesService.js - Maps API integration
Nominatim Address Enrichment
Overview
Venues imported from OSM may have placeholder or missing addresses (just coordinates). We use Nominatim (OSM's free reverse geocoding API) to enrich venue addresses with structured addressComponents.
Prefetch Strategy
Instead of enriching addresses only when a user opens a venue (causing visible delay), we prefetch addresses for all venues in the viewport as they load:
┌─────────────────────────────────────────────────────────────┐
│ User opens app → getNearbyVenues() returns 20 venues │
│ │
│ For each venue missing addressComponents: │
│ → Queue for prefetch (rate-limited 1 req/sec) │
│ → Cloud Function calls Nominatim │
│ → Update Firestore with addressComponents │
│ │
│ By the time user opens a venue, address is already enriched │
└─────────────────────────────────────────────────────────────┘Implementation
Files:
src/lib/venueService.js-prefetchVenueAddresses(),enrichVenueAddress()firebase-functions/index.js-enrichVenueAddressCloud Functionsrc/screens/dashboard/Dashboard.jsx- Calls prefetch when venues load
Rate Limiting:
- Nominatim policy: max 1 request/second
- Prefetch queue processes one venue every 1.1 seconds
- Skips venues that already have
addressComponents - Session-level deduplication prevents re-checking same venue
Behavior:
- When venues load (initial, load more, filter),
prefetchVenueAddresses()is called - Venues without
addressComponentsare queued - Queue processes in background (non-blocking)
- Cloud Function calls Nominatim and updates Firestore
- Future reads get enriched address instantly
Fallback
If a user opens a venue before prefetch completes, enrichVenueAddress() is still called on-demand (existing behavior). The prefetch just makes this faster for most users.
Monitoring
In development, check prefetch stats:
import { getPrefetchQueueStats } from './lib/venueService'
console.log(getPrefetchQueueStats())
// { pending: 5, enrichedThisSession: 12, processing: true }See Also
- LOCATION_STACK.md - Full location architecture
- DATABASE_SCALING.md - Firestore cost considerations
- Issue #210 - Location caching implementation
- Issue #155 - Server-side validation