Skip to content

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:

  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 interval14Rate limit - won't refresh more often than this
Moderately stale14-30Return cached + background refresh
Very stale30+Block and wait for fresh API data

Configuration: src/lib/venueConfig.jsVENUE_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)

  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
// 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 = 90

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.



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 - enrichVenueAddress Cloud Function
  • src/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:

  1. When venues load (initial, load more, filter), prefetchVenueAddresses() is called
  2. Venues without addressComponents are queued
  3. Queue processes in background (non-blocking)
  4. Cloud Function calls Nominatim and updates Firestore
  5. 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:

javascript
import { getPrefetchQueueStats } from './lib/venueService'
console.log(getPrefetchQueueStats())
// { pending: 5, enrichedThisSession: 12, processing: true }

See Also

Built with VitePress