How to Design a Proximity / Location-Based Service
Building Yelp, Uber, and Google Maps Place Search at scale: geospatial indexing, real-time location tracking, and distance calculations
Table of Contents
- Introduction — The Location-Based Service Landscape
- Functional & Non-Functional Requirements
- Capacity Estimation & Back-of-Envelope
- Data Model & Storage
- API Design
- Geospatial Indexing Deep Dive
- High-Level Architecture
- Quadtree & Spatial Partitioning
- Geohash & S2 Geometry
- Distance Calculation Algorithms
- Proximity Search Implementation
- Real-Time Location Tracking
- Caching Strategy for Geo Data
- Geo-Fencing & Notifications
- Result Ranking & Relevance
- Scaling the Geo Service
- Data Consistency & Replication
- Monitoring & Observability
- Cost Estimation
- Case Studies — Production Systems
- Edge Cases
- Interview Q&A
- Conclusion
1. Introduction — The Location-Based Service Landscape
Location-based services power some of the most widely used applications on the planet. Google Maps processes 5 billion place searches per day and provides real-time navigation for over 1 billion users. Uber completes 19 million trips per day, each requiring real-time proximity matching between riders and drivers. Yelp serves 38 million unique monthly visitors looking for nearby restaurants, shops, and services. These systems share a common foundation: the ability to efficiently store, query, and serve geospatial data at massive scale.
Building a proximity service requires solving several fundamental geospatial problems. How do you index 200 million points of interest on a sphere? How do you find all restaurants within 5 kilometers of a user's current location in under 50 milliseconds? How do you track the real-time positions of 5 million moving vehicles? The answer lies in specialized spatial data structures — quadtrees, geohashes, and S2 geometry — combined with distributed systems techniques for caching, sharding, and replication.
2. Functional & Non-Functional Requirements
Functional Requirements
| # | Requirement | Priority | Details |
|---|---|---|---|
| F1 | Add/update/delete places | Must | Business owners can manage their listings |
| F2 | Nearby search | Must | Find places within a radius of user location |
| F3 | Text search | Must | Search places by name, category, keyword |
| F4 | Place details | Must | Get full info: address, hours, photos, reviews |
| F5 | Distance calculation | Must | Compute walking/driving distance between two points |
| F6 | Real-time location | Should | Track driver/rider positions in real-time |
| F7 | Geo-fencing | Should | Trigger events when devices enter/exit areas |
| F8 | Directions/routing | Should | Turn-by-turn navigation between two points |
| F9 | Reviews & ratings | Nice | User-generated reviews with star ratings |
| F10 | Traffic data | Nice | Real-time traffic conditions on road segments |
Non-Functional Requirements
| Requirement | Target | Rationale |
|---|---|---|
| Search latency | < 100ms (p99) | Users expect instant nearby results |
| Availability | 99.99% | Location services are critical for ride-hailing |
| Location accuracy | 10 meters (GPS) | Accurate enough for nearby search |
| Update propagation | < 5 seconds | New places should appear quickly |
| Scale | 200M places, 50M DAU | Global scale for major platforms |
| Real-time tracking | 1 second updates | Driver position updates every second |
3. Capacity Estimation & Back-of-Envelope
Daily Volume Estimates
| Metric | Calculation | Result |
|---|---|---|
| Places in database | Global POI count | 200 million |
| Daily active users | Given | 50 million |
| Searches per user per day | Given | 10 |
| Total searches per day | 50M × 10 | 500 million |
| Average QPS (searches) | 500M / 86,400 | ~5,787 QPS |
| Peak QPS (3x) | 5,787 × 3 | ~17,361 QPS |
| Place updates per day | 1% of 200M modified | 2 million |
| Real-time location updates | 1M drivers × 3600/hr | 3.6 billion/day |
| Location update QPS | 3.6B / 86,400 | ~41,667 QPS |
Storage Estimates
| Data | Size per Record | Count | Total |
|---|---|---|---|
| Place data | ~2 KB | 200M | ~400 GB |
| Place metadata (hours, photos refs) | ~5 KB | 200M | ~1 TB |
| Reviews | ~500 bytes | 2B | ~1 TB |
| Location snapshots (real-time) | ~100 bytes | 1M active | ~100 MB |
| Geo index (quadtree nodes) | ~50 bytes per node | 500M nodes | ~25 GB |
| Geohash index | ~20 bytes | 200M | ~4 GB |
| Total (hot data) | ~2.4 TB |
Network Bandwidth
| Operation | Requests/day | Avg Response | Daily Bandwidth |
|---|---|---|---|
| Nearby search | 500M | 10 KB | ~5 TB |
| Place details | 200M | 50 KB | ~10 TB |
| Location updates (ingest) | 3.6B | 100 bytes | ~360 GB |
| Location broadcasts (egress) | 10B | 200 bytes | ~2 TB |
| Total | ~17.5 TB/day |
4. Data Model & Storage
Entity Relationship
PostgreSQL Schema with PostGIS
SQL
CREATE EXTENSION IF NOT EXISTS postgis;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE TABLE places (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
category VARCHAR(100) NOT NULL,
subcategory VARCHAR(100),
description TEXT,
latitude DOUBLE PRECISION NOT NULL,
longitude DOUBLE PRECISION NOT NULL,
geohash VARCHAR(12) NOT NULL,
address TEXT,
city VARCHAR(100),
state VARCHAR(100),
country VARCHAR(2),
postal_code VARCHAR(20),
phone VARCHAR(20),
website VARCHAR(500),
rating DECIMAL(2,1) DEFAULT 0.0,
review_count INTEGER DEFAULT 0,
price_level SMALLINT,
opening_hours JSONB,
photos TEXT[],
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- PostGIS spatial index for proximity queries
CREATE INDEX idx_places_location ON places
USING GIST (ST_Point(longitude, latitude));
-- Geohash index for grid-based lookups
CREATE INDEX idx_places_geohash ON places(geohash);
-- Trigram index for text search
CREATE INDEX idx_places_name_trgm ON places
USING GIN (name gin_trgm_ops);
-- Composite index for category + location
CREATE INDEX idx_places_category_geo ON places(category, geohash)
WHERE is_active = TRUE;
CREATE TABLE reviews (
id BIGSERIAL PRIMARY KEY,
place_id BIGINT REFERENCES places(id),
user_id BIGINT REFERENCES users(id),
rating SMALLINT NOT NULL CHECK (rating BETWEEN 1 AND 5),
content TEXT,
photos TEXT[],
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE (place_id, user_id)
);
CREATE INDEX idx_reviews_place ON reviews(place_id, created_at DESC);
CREATE TABLE user_locations (
user_id BIGINT PRIMARY KEY,
latitude DOUBLE PRECISION NOT NULL,
longitude DOUBLE PRECISION NOT NULL,
accuracy DOUBLE PRECISION,
speed DOUBLE PRECISION,
heading DOUBLE PRECISION,
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_user_location_geo ON user_locations
USING GIST (ST_Point(longitude, latitude));
Redis Location Storage (Real-Time)
C#
// Redis GEO commands for real-time proximity queries
// Add driver location
await redis.GeoAddAsync("drivers:active",
new GeoEntry(longitude, latitude, driverId));
// Find drivers within 5km
var nearby = await redis.GeoSearchAsync("drivers:active",
new GeoSearchBox(
中心Longitude, centerLatitude,
5.0, GeoUnit.Kilometers),
SortOrder.Asc,
10); // limit to 10 results
// Calculate distance between two drivers
var distance = await redis.GeoDistanceAsync(
"drivers:active", driver1Id, driver2Id, GeoUnit.Kilometers);
5. API Design
REST API
HTTP
// Nearby search
GET /api/v1/places/nearby?lat=37.7749&lng=-122.4194&radius=5000&category=restaurant&page=20&cursor=abc123
// Response
{
"places": [
{
"id": "p_12345",
"name": "Golden Gate Pizza",
"category": "restaurant",
"subcategory": "pizza",
"location": { "lat": 37.7751, "lng": -122.4183 },
"distance_meters": 245,
"rating": 4.5,
"review_count": 328,
"price_level": 2,
"is_open_now": true,
"photo_url": "https://cdn.example.com/places/p_12345/main.jpg"
}
],
"total": 847,
"cursor": "next_cursor_token",
"search_center": { "lat": 37.7749, "lng": -122.4194 },
"radius_meters": 5000
}
// Text search
GET /api/v1/places/search?q=sushi+near+me&lat=37.7749&lng=-122.4194
// Place details
GET /api/v1/places/{place_id}
// Add/update place
POST /api/v1/places
{
"name": "New Restaurant",
"category": "restaurant",
"latitude": 37.7749,
"longitude": -122.4194,
"address": "123 Main St, San Francisco, CA 94105",
"phone": "+1-415-555-0123",
"website": "https://example.com",
"opening_hours": {
"monday": {"open": "09:00", "close": "22:00"},
"tuesday": {"open": "09:00", "close": "22:00"}
}
}
// Update driver location (ride-hailing)
POST /api/v1/locations/update
{
"latitude": 37.7749,
"longitude": -122.4194,
"speed": 25.5,
"heading": 180,
"accuracy": 10
}
// Get nearby drivers
GET /api/v1/drivers/nearby?lat=37.7749&lng=-122.4194&radius=2000&vehicle_type=sedan
// Geo-fence check
POST /api/v1/geofence/check
{
"latitude": 37.7749,
"longitude": -122.4194,
"device_id": "device_abc"
}
// Directions
GET /api/v1/directions?origin=37.7749,-122.4194&destination=37.7849,-122.4094&mode=driving
WebSocket API for Real-Time Tracking
WebSocket
// Client connects to track a specific driver
wss://api.example.com/v1/track/{driver_id}
// Server pushes location updates
{
"type": "location_update",
"driver_id": "d_12345",
"latitude": 37.7751,
"longitude": -122.4183,
"speed": 25.5,
"heading": 180,
"timestamp": "2025-01-15T10:30:00Z",
"eta_seconds": 300
}
// Server pushes status changes
{
"type": "status_change",
"driver_id": "d_12345",
"status": "arriving",
"estimated_arrival": "2025-01-15T10:35:00Z"
}
6. Geospatial Indexing Deep Dive
Geospatial indexing is the core algorithmic challenge in proximity services. Standard B-tree indexes cannot efficiently answer "find all points within 5km" because latitude/longitude don't form a natural ordering for range queries. We need specialized spatial data structures that partition the Earth's surface into regions and allow fast proximity lookups.
Recursive 2D space division
Good for 2D point data] GH[Geohash
Z-order curve encoding
Good for grid-based lookups] S2[S2 Geometry
Spherical geometry
Best for global coverage] H3[H3 (Uber)
Hexagonal grid
Best for均匀 cell sizes] PG["PostGIS (R-tree)
Database-native
Good for moderate scale"] end QT -->|"In-memory services"| APP[Application Layer] GH -->|"Simple implementation"| APP S2 -->|"Global accuracy"| APP H3 -->|"Ride-hailing"| APP PG -->|"Standard queries"| DB[(Database)]
Why Standard Indexes Fail
A B-tree index on latitude or longitude independently cannot answer spatial queries efficiently. If you search for places with latitude between 37.77 and 37.78 AND longitude between -122.42 and -122.41, the database must perform two separate range scans and then intersect the results. For a table with 200 million rows, this intersection is extremely slow because the two ranges are independent — the database cannot use the index to narrow both dimensions simultaneously.
Spatial indexes solve this by encoding both dimensions into a single value that preserves spatial locality. Points that are close together in physical space should have similar index values. This allows the database to perform a single range scan instead of two intersecting scans, reducing query time from seconds to milliseconds.
Spatial Index Comparison
| Index Type | Query Time | Build Time | Memory | Accuracy |
|---|---|---|---|---|
| B-tree (lat) + B-tree (lng) | 500ms+ | Fast | Low | Exact |
| PostGIS R-tree (GiST) | 10-50ms | Medium | Medium | Exact |
| Quadtree (in-memory) | 0.1-1ms | Slow | High | Exact |
| Geohash prefix | 1-5ms | Fast | Low | Approximate |
| S2 cells | 0.5-2ms | Fast | Low | Approximate |
| H3 hexagons | 0.5-2ms | Fast | Low | Approximate |
7. High-Level Architecture
Component Responsibilities
| Component | Responsibility | Technology |
|---|---|---|
| Place Search Service | Nearby search, text search, filtering | PostGIS + Redis GEO |
| Place Detail Service | Full place info, reviews, photos | PostgreSQL + S3 |
| Location Tracking Service | Real-time driver/rider positions | Redis GEO + Kafka |
| Geo-Fence Service | Enter/exit detection for regions | Redis + PostGIS |
| Index Service | Build and maintain spatial indexes | Quadtree (in-memory) + ES |
| Redis GEO | Fast proximity queries, real-time data | Redis Cluster with GEO |
| PostGIS | Durable place storage, complex queries | PostgreSQL + PostGIS extension |
| Elasticsearch | Full-text search, autocomplete | ES cluster with geo_point |
8. Quadtree & Spatial Partitioning
A quadtree recursively divides a 2D space into four quadrants. Each node represents a rectangular region, and points are stored in leaf nodes. This structure enables efficient spatial queries because entire subtrees can be pruned if their bounding box doesn't overlap with the search area.
Quadtree Implementation
C#
public class QuadTree<T> where T : ILocatable
{
private const int MaxPointsPerNode = 50;
private const int MaxDepth = 15;
private readonly BoundingBox _bounds;
private readonly List<T> _points;
private readonly QuadTree<T>[] _children;
private readonly int _depth;
private bool _divided;
public QuadTree(BoundingBox bounds, int depth = 0)
{
_bounds = bounds;
_points = new List<T>();
_children = new QuadTree<T>[4];
_depth = depth;
_divided = false;
}
public bool Insert(T point)
{
if (!_bounds.Contains(point.Latitude, point.Longitude))
return false;
if (_points.Count < MaxPointsPerNode || _depth >= MaxDepth)
{
_points.Add(point);
return true;
}
if (!_divided) Subdivide();
foreach (var child in _children)
{
if (child.Insert(point)) return true;
}
return false;
}
public List<T> Query(BoundingBox range)
{
var results = new List<T>();
if (!_bounds.Intersects(range))
return results;
foreach (var point in _points)
{
if (range.Contains(point.Latitude, point.Longitude))
results.Add(point);
}
if (_divided)
{
foreach (var child in _children)
results.AddRange(child.Query(range));
}
return results;
}
private void Subdivide()
{
double midX = (_bounds.MinLng + _bounds.MaxLng) / 2;
double midY = (_bounds.MinLat + _bounds.MaxLat) / 2;
_children[0] = new QuadTree<T>(
new BoundingBox(_bounds.MinLat, midY, _bounds.MinLng, midX), _depth + 1);
_children[1] = new QuadTree<T>(
new BoundingBox(_bounds.MinLat, midY, midX, _bounds.MaxLng), _depth + 1);
_children[2] = new QuadTree<T>(
new BoundingBox(midY, _bounds.MaxLat, _bounds.MinLng, midX), _depth + 1);
_children[3] = new QuadTree<T>(
new BoundingBox(midY, _bounds.MaxLat, midX, _bounds.MaxLng), _depth + 1);
_divided = true;
// Redistribute existing points to children
var existing = new List<T>(_points);
_points.Clear();
foreach (var point in existing)
{
foreach (var child in _children)
{
if (child.Insert(point)) break;
}
}
}
}
Quadtree Properties
| Property | Value | Impact |
|---|---|---|
| Tree depth (200M points) | 15 levels | Fast traversal |
| Max points per leaf | 50 | Balanced query performance |
| Memory per node | ~100 bytes | Total: ~500M nodes = ~50GB |
| Query complexity | O(log n) average | Millions of operations/sec |
| Insert complexity | O(log n) average | Batch rebuild: minutes |
(-90,-180) to (90,180)"] NW["NW: (0-90, -180-0)
North America, Europe"] NE["NE: (0-90, 0-180)
Asia, Oceania"] SW["SW: (-90-0, -180-0)
South America"] SE["SE: (-90-0, 0-180)
Africa, Australia"] NW_NW["NW sub: US West"] NW_NE["NW sub: US East"] NW_SW["NW sub: Mexico"] NW_SE["NW sub: Europe"] ROOT --> NW & NE & SW & SE NW --> NW_NW & NW_NE & NW_SW & NW_SE
9. Geohash & S2 Geometry
Geohash Encoding
A geohash encodes a latitude/longitude pair into a short string of characters. The key property is that places with similar geohash prefixes are physically close together. This allows proximity searches to be performed as simple string prefix queries. For example, all places starting with "9q8yy" are within approximately 5km of each other.
C#
public static class GeohashEncoder
{
private const string Base32 = "0123456789bcdefghjkmnpqrstuvwxyz";
public static string Encode(double latitude, double longitude, int precision = 12)
{
double minLat = -90, maxLat = 90;
double minLng = -180, maxLng = 180;
var sb = new StringBuilder();
bool isLng = true;
int bit = 0;
int ch = 0;
while (sb.Length < precision)
{
if (isLng)
{
double mid = (minLng + maxLng) / 2;
if (longitude >= mid)
{
ch |= (1 << (4 - bit));
minLng = mid;
}
else
{
maxLng = mid;
}
}
else
{
double mid = (minLat + maxLat) / 2;
if (latitude >= mid)
{
ch |= (1 << (4 - bit));
minLat = mid;
}
else
{
maxLat = mid;
}
}
isLng = !isLng;
bit++;
if (bit == 5)
{
sb.Append(Base32[ch]);
bit = 0;
ch = 0;
}
}
return sb.ToString();
}
public static BoundingBox DecodeBounds(string geohash)
{
double minLat = -90, maxLat = 90;
double minLng = -180, maxLng = 180;
bool isLng = true;
foreach (char c in geohash)
{
int cd = Base32.IndexOf(c);
for (int bit = 4; bit >= 0; bit--)
{
int mask = 1 << bit;
if (isLng)
{
double mid = (minLng + maxLng) / 2;
if ((cd & mask) != 0) minLng = mid;
else maxLng = mid;
}
else
{
double mid = (minLat + maxLat) / 2;
if ((cd & mask) != 0) minLat = mid;
else maxLat = mid;
}
isLng = !isLng;
}
}
return new BoundingBox(minLat, maxLat, minLng, maxLng);
}
}
Geohash Precision Levels
| Precision | Cell Size | Example | Use Case |
|---|---|---|---|
| 1 | 5,000 km × 5,000 km | s | Continental routing |
| 2 | 1,250 km × 625 km | st | Country-level |
| 3 | 156 km × 156 km | stu | State/province |
| 4 | 39 km × 19 km | stuw | Metro area |
| 5 | 4.9 km × 4.9 km | stuwu | City neighborhood |
| 6 | 1.2 km × 609 m | stuwup | Street level |
| 7 | 153 m × 153 m | stuwupq | Block level |
| 8 | 38 m × 19 m | stuwupqn | Building level |
| 9 | 4.8 m × 4.8 m | stuwupqnj | Precise location |
Proximity Search with Geohash
C#
public class GeohashProximitySearch
{
private readonly IDatabase _redis;
public async Task<List<Place>> FindNearby(
double lat, double lng, double radiusKm, string? category = null)
{
// Step 1: Determine geohash precision for the radius
int precision = GetPrecisionForRadius(radiusKm);
string centerHash = GeohashEncoder.Encode(lat, lng, precision);
// Step 2: Get all 8 neighboring cells + center
string[] neighbors = GetNeighborHashes(centerHash);
var allHashes = new List<string>(neighbors) { centerHash };
// Step 3: Query places in each cell
var candidates = new List<Place>();
foreach (var hash in allHashes)
{
var places = await _redis.SetMembersAsync(
$"geohash:{hash}" + (category != null ? $":{category}" : ""));
candidates.AddRange(places.Select(p => Deserialize<Place>(p)));
}
// Step 4: Filter by exact distance
return candidates
.Where(p => HaversineDistance(lat, lng, p.Latitude, p.Longitude) <= radiusKm)
.OrderBy(p => HaversineDistance(lat, lng, p.Latitude, p.Longitude))
.ToList();
}
private int GetPrecisionForRadius(double radiusKm) => radiusKm switch
{
< 0.2 => 9,
< 1 => 7,
< 5 => 6,
< 20 => 5,
< 80 => 4,
< 300 => 3,
_ => 2
};
private string[] GetNeighborHashes(string geohash)
{
var bounds = GeohashEncoder.DecodeBounds(geohash);
double lat = (bounds.MinLat + bounds.MaxLat) / 2;
double lng = (bounds.MinLng + bounds.MaxLng) / 2;
double step = Math.Max(bounds.MaxLat - bounds.MinLat, bounds.MaxLng - bounds.MinLng);
string[] neighbors = new string[8];
int idx = 0;
for (int dlat = -1; dlat <= 1; dlat++)
{
for (int dlng = -1; dlng <= 1; dlng++)
{
if (dlat == 0 && dlng == 0) continue;
neighbors[idx++] = GeohashEncoder.Encode(
lat + dlat * step, lng + dlng * step, geohash.Length);
}
}
return neighbors;
}
}
S2 Geometry vs Geohash
| Property | Geohash | S2 Geometry | H3 |
|---|---|---|---|
| Cell shape | Rectangle | Irregular quad | Hexagon |
| Edge distortion | High at poles | Low (spherical) | Very low |
| Max level | 12 | 30 | 15 |
| Library | Manual or Redis | Google S2 | Uber H3 |
| Neighboring cells | 8 (Moore) | 6 (variable) | 6 (uniform) |
| Best for | Simple lookups | Global mapping | Ride-hailing |
10. Distance Calculation Algorithms
Haversine Formula
The Haversine formula calculates the great-circle distance between two points on a sphere. It is the most commonly used distance function for geographic coordinates because it accounts for the Earth's curvature.
C#
public static class DistanceCalculator
{
private const double EarthRadiusKm = 6371.0;
public static double HaversineDistance(
double lat1, double lng1, double lat2, double lng2)
{
double dLat = ToRadians(lat2 - lat1);
double dLng = ToRadians(lng2 - lng1);
double a = Math.Sin(dLat / 2) * Math.Sin(dLat / 2) +
Math.Cos(ToRadians(lat1)) * Math.Cos(ToRadians(lat2)) *
Math.Sin(dLng / 2) * Math.Sin(dLng / 2);
double c = 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a));
return EarthRadiusKm * c;
}
// Faster approximation for sorting (not exact)
public static double FastDistance(
double lat1, double lng1, double lat2, double lng2)
{
double dx = (lng2 - lng1) * Math.Cos(ToRadians((lat1 + lat2) / 2));
double dy = lat2 - lat1;
return Math.Sqrt(dx * dx + dy * dy) * 111.32; // km per degree at equator
}
// Vincenty formula for higher accuracy (ellipsoid model)
public static double VincentyDistance(
double lat1, double lng1, double lat2, double lng2)
{
double a = 6378137; // WGS-84 semi-major axis
double f = 1 / 298.257223563;
double b = a * (1 - f);
double L = ToRadians(lng2 - lng1);
double U1 = Math.Atan((1 - f) * Math.Tan(ToRadians(lat1)));
double U2 = Math.Atan((1 - f) * Math.Tan(ToRadians(lat2)));
double sinU1 = Math.Sin(U1), cosU1 = Math.Cos(U1);
double sinU2 = Math.Sin(U2), cosU2 = Math.Cos(U2);
double lambda = L, lambdaP;
int maxIter = 100;
do
{
double sinLambda = Math.Sin(lambda), cosLambda = Math.Cos(lambda);
double sinSigma = Math.Sqrt(
(cosU2 * sinLambda) * (cosU2 * sinLambda) +
(cosU1 * sinU2 - sinU1 * cosU2 * cosLambda) *
(cosU1 * sinU2 - sinU1 * cosU2 * cosLambda));
if (sinSigma == 0) return 0;
double cosSigma = sinU1 * sinU2 + cosU1 * cosU2 * cosLambda;
double sigma = Math.Atan2(sinSigma, cosSigma);
double sinAlpha = cosU1 * cosU2 * sinLambda / sinSigma;
double cos2Alpha = 1 - sinAlpha * sinAlpha;
double cos2SigmaM = cosSigma - 2 * sinU1 * sinU2 / cos2Alpha;
double C = f / 16 * cos2Alpha * (4 + f * (4 - 3 * cos2Alpha));
lambdaP = lambda;
lambda = L + (1 - C) * f * sinAlpha *
(sigma + C * sinSigma *
(cos2SigmaM + C * cosSigma *
(-1 + 2 * cos2SigmaM * cos2SigmaM)));
} while (Math.Abs(lambda - lambdaP) > 1e-12 && --maxIter > 0);
double u2 = cos2Alpha * (a * a - b * b) / (b * b);
double A2 = 1 + u2 / 16384 * (4096 + u2 * (-768 + u2 * (320 - 175 * u2)));
double B2 = u2 / 1024 * (256 + u2 * (-128 + u2 * (74 - 47 * u2)));
double deltaSigma = B2 * Math.Sin(sigma) *
(cos2SigmaM + B2 / 4 *
(cosSigma * (-1 + 2 * cos2SigmaM * cos2SigmaM) -
B2 / 6 * cos2SigmaM * (-3 + 4 * sinSigma * sinSigma) *
(-3 + 4 * cos2SigmaM * cos2SigmaM)));
return b * A2 * (sigma - deltaSigma) / 1000;
}
private static double ToRadians(double degrees) => degrees * Math.PI / 180;
}
Algorithm Comparison
| Algorithm | Accuracy | Speed | Best For |
|---|---|---|---|
| Haversine | ±0.5% | Fast | Most proximity searches |
| Euclidean (flat) | ±1% (near equator) | Fastest | Sorting, filtering |
| Vincenty | ±0.5mm | Slow (iterative) | Precise surveying |
| Fast approximation | ±2% | Very fast | Pre-filtering, ranking |
| Manhattan | Approximate | Fast | Grid-based cities |
11. Proximity Search Implementation
Multi-Stage Search Pipeline
lat, lng, radius, filters"] L1["Stage 1: Geohash Grid
Quick candidate set"] L2["Stage 2: Distance Filter
Exact Haversine"] L3["Stage 3: Business Filter
Category, hours, rating"] L4["Stage 4: Ranking
Distance + relevance"] RESP["Sorted Results
with pagination"] REQ --> L1 L1 -->|"~1000 candidates"| L2 L2 -->|"~200 within radius"| L3 L3 -->|"~50 matching filters"| L4 L4 -->|"Top 20"| RESP
C#
public class ProximitySearchService
{
private readonly IRedisGEO _redisGEO;
private readonly IPlaceRepository _repository;
private readonly ICacheService _cache;
public async Task<SearchResult> SearchNearby(SearchRequest request)
{
// Check cache for identical query
string cacheKey = $"search:{request.Lat}:{request.Lng}:{request.Radius}:{request.Category}";
var cached = await _cache.GetAsync<SearchResult>(cacheKey);
if (cached != null) return cached;
// Stage 1: Redis GEO radius search (fast, approximate)
var geoResults = await _redisGEO.RadiusSearchAsync(
centerLon: request.Lng,
centerLat: request.Lat,
radius: request.Radius,
unit: GeoUnit.Meters,
sort: GeoSortDistance,
count: 200);
// Stage 2: Enrich with database data
var placeIds = geoResults.Select(r => r.MemberId).ToList();
var places = await _repository.GetByIds(placeIds);
// Stage 3: Apply filters
var filtered = places
.Where(p => p.IsActive)
.Where(p => request.Category == null || p.Category == request.Category)
.Where(p => request.MinRating == null || p.Rating >= request.MinRating)
.Where(p => !request.OpenNowOnly || IsOpenNow(p))
.ToList();
// Stage 4: Score and rank
var scored = filtered.Select(p => new ScoredPlace
{
Place = p,
Distance = HaversineDistance(request.Lat, request.Lng,
p.Latitude, p.Longitude),
Score = CalculateScore(p, request)
})
.OrderBy(s => s.Distance)
.ThenByDescending(s => s.Score)
.Skip(request.Offset)
.Take(request.Limit)
.ToList();
var result = new SearchResult
{
Places = scored,
TotalCount = filtered.Count,
SearchCenter = new Coordinate(request.Lat, request.Lng)
};
// Cache for 5 minutes (nearby results change slowly)
await _cache.SetAsync(cacheKey, result, TimeSpan.FromMinutes(5));
return result;
}
private double CalculateScore(Place place, SearchRequest request)
{
double score = place.Rating * 20; // Max 100
score += Math.Min(place.ReviewCount / 100.0, 30); // Max 30
if (place.PriceLevel == request.PreferredPrice) score += 20;
if (place.IsVerified) score += 10;
return score;
}
private bool IsOpenNow(Place place)
{
if (place.OpeningHours == null) return true;
var now = DateTime.Now;
var todayHours = place.OpeningHours.GetValueOrDefault(now.DayOfWeek.ToString().ToLower());
if (todayHours == null) return false;
return now.TimeOfDay >= todayHours.Open && now.TimeOfDay <= todayHours.Close;
}
}
Search Performance Benchmarks
| Radius | Candidates (avg) | After Distance Filter | P50 Latency | P99 Latency |
|---|---|---|---|---|
| 500m | 50 | 35 | 8ms | 25ms |
| 2km | 200 | 150 | 12ms | 40ms |
| 5km | 500 | 380 | 18ms | 60ms |
| 10km | 1,500 | 1,100 | 25ms | 85ms |
| 50km | 10,000 | 7,500 | 60ms | 200ms |
12. Real-Time Location Tracking
Real-time location tracking is essential for ride-hailing, delivery, and fleet management. Drivers update their positions every 1-3 seconds, and riders need to see these positions in real-time. The system must handle millions of concurrent location updates and distribute them to interested viewers via WebSocket connections.
Location Update Service
C#
public class LocationTrackingService
{
private readonly IDatabase _redis;
private readonly IKafkaProducer _kafka;
private readonly IWebSocketManager _wsManager;
public async Task UpdateLocation(LocationUpdate update)
{
// Validate coordinates
if (!IsValidCoordinate(update.Latitude, update.Longitude))
throw new ArgumentException("Invalid coordinates");
// Update Redis GEO (for proximity queries)
await _redis.GeoAddAsync("drivers:active",
new GeoEntry(update.Longitude, update.Latitude, update.DriverId));
// Store latest position with metadata
await _redis.HashSetAsync($"driver:{update.DriverId}",
new HashEntry[] {
new("lat", update.Latitude),
new("lng", update.Longitude),
new("speed", update.Speed),
new("heading", update.Heading),
new("updated_at", DateTime.UtcNow.Ticks)
});
// Set expiry (remove if no update for 30 seconds)
await _redis.KeyExpireAsync($"driver:{update.DriverId}",
TimeSpan.FromSeconds(30));
// Publish to Kafka for downstream consumers
await _kafka.ProduceAsync("location-updates", update.DriverId, update);
// Notify subscribers watching this driver
await NotifySubscribers(update);
}
private async Task NotifySubscribers(LocationUpdate update)
{
// Find riders watching this driver
var watchers = await _redis.SetMembersAsync($"watchers:{update.DriverId}");
foreach (var watcherId in watchers)
{
await _wsManager.SendToConnection(watcherId, new
{
type = "location_update",
driver_id = update.DriverId,
latitude = update.Latitude,
longitude = update.Longitude,
speed = update.Speed,
heading = update.Heading,
timestamp = DateTime.UtcNow
});
}
}
public async Task<List<DriverLocation>> GetNearbyDrivers(
double lat, double lng, double radiusKm, string vehicleType)
{
var results = await _redis.GeoSearchAsync(
"drivers:active",
new GeoSearchBox(lng, lat, radiusKm, GeoUnit.Kilometers),
sort: GeoSort.Distance,
count: 20);
return results.Select(r => new DriverLocation
{
DriverId = r.MemberId,
Latitude = r.GeoCoordinate.Latitude,
Longitude = r.GeoCoordinate.Longitude,
DistanceKm = r.Distance ?? 0
}).ToList();
}
}
Location Update Protocol
| Component | Update Frequency | Protocol | Battery Impact |
|---|---|---|---|
| Driver app (moving) | Every 3 seconds | HTTP POST (batched) | Medium (GPS + network) |
| Driver app (idle) | Every 30 seconds | HTTP POST | Low |
| Rider app (watching) | N/A (receives via WS) | WebSocket | Low |
| Fleet manager | Every 5 seconds | HTTP POST | Medium |
WebSocket Subscription Management
C#
public class WebSocketSubscriptionManager
{
private readonly IDatabase _redis;
private readonly ConcurrentDictionary<string, WebSocket> _connections;
public async Task SubscribeToDriver(string viewerId, string driverId)
{
// Register the viewer as watching this driver
await _redis.SetAddAsync($"watchers:{driverId}", viewerId);
// Store the viewer's connection
if (_connections.TryGetValue(viewerId, out var ws) && ws.State == WebSocketState.Open)
{
// Connection is active
}
}
public async Task UnsubscribeFromDriver(string viewerId, string driverId)
{
await _redis.SetRemoveAsync($"watchers:{driverId}", viewerId);
}
public async Task CleanupStaleSubscriptions()
{
// Remove subscriptions for disconnected clients
var allWatchers = await _redis.KeysAsync("watchers:*");
foreach (var key in allWatchers)
{
var driverId = key.ToString().Split(':').Last();
var watchers = await _redis.SetMembersAsync(key);
foreach (var watcher in watchers)
{
if (!_connections.ContainsKey(watcher.ToString()) ||
_connections[watcher.ToString()].State != WebSocketState.Open)
{
await _redis.SetRemoveAsync(key, watcher);
}
}
}
}
}
13. Caching Strategy for Geo Data
Multi-Level Cache Architecture
| Level | Location | TTL | Hit Rate | Data |
|---|---|---|---|---|
| L1: Client | Mobile app memory | 30 sec | 40% | Recent search results, place details |
| L2: CDN | Edge nodes | 5 min | 30% | Place details, photos |
| L3: Redis | Application layer | 5 min | 20% | Nearby search results, driver locations |
| L4: Database | PostgreSQL | N/A | 10% | Full place data, reviews |
C#
public class GeoCacheService
{
private readonly IDatabase _redis;
private readonly ICDNService _cdn;
private readonly IPlaceRepository _repository;
public async Task<PlaceDetails> GetPlaceDetails(string placeId)
{
// L1: Client-side cache (handled by app)
// L2: CDN cache for place details
var cdnResult = await _cdn.GetAsync<PlaceDetails>(
$"places/{placeId}");
if (cdnResult != null) return cdnResult;
// L3: Redis cache
var redisResult = await _redis.StringGetAsync($"place:{placeId}");
if (!redisResult.IsNullOrEmpty)
{
var details = JsonSerializer.Deserialize<PlaceDetails>(redisResult);
// Also populate CDN for next time
await _cdn.SetAsync($"places/{placeId}", details, TimeSpan.FromMinutes(5));
return details;
}
// L4: Database
var place = await _repository.GetByIdAsync(placeId);
if (place == null) throw new NotFoundException("Place not found");
var placeDetails = MapToDetails(place);
await _redis.StringSetAsync($"place:{placeId}",
JsonSerializer.Serialize(placeDetails), TimeSpan.FromMinutes(10));
await _cdn.SetAsync($"places/{placeId}", placeDetails, TimeSpan.FromMinutes(5));
return placeDetails;
}
public async Task CacheSearchResults(
string cacheKey, List<Place> results, TimeSpan ttl)
{
// Cache with geohash-based key for spatial locality
await _redis.StringSetAsync(
$"search:{cacheKey}",
JsonSerializer.Serialize(results),
ttl);
// Also cache in a sorted set for nearby-query deduplication
await _redis.SortedSetAddAsync("search:recent_keys",
cacheKey, DateTimeOffset.UtcNow.ToUnixTimeSeconds());
}
}
Cache Warming Strategy
| Scenario | Strategy | Trigger |
|---|---|---|
| Cold start | Pre-warm top 100K places per city | Deployment |
| New area | Lazy load on first query | Cache miss |
| Trending location | Predictive pre-warming | Event detection (sports, concerts) |
| Driver influx | Batch pre-load driver positions | Rush hour schedule |
14. Geo-Fencing & Notifications
Geo-fencing creates virtual boundaries around real-world locations. When a device enters or exits these boundaries, the system triggers actions — sending notifications, logging events, or updating status. This is critical for delivery apps (notify when driver arrives), ride-hailing (estimate arrival), and marketing (location-based promotions).
Geo-Fence Types
| Type | Shape | Algorithm | Use Case |
|---|---|---|---|
| Circular | Center + radius | Haversine distance | Simple proximity alerts |
| Polygon | Custom boundary | Ray casting / winding number | Delivery zones, city limits |
| Corridor | Path + width | Point-to-line distance | Route monitoring |
| Cell-based | H3/Geohash cells | Cell membership | Large-scale zones |
C#
public class GeoFenceService
{
private readonly IDatabase _redis;
private readonly IKafkaProducer _kafka;
public async Task<bool> CheckGeoFence(
double lat, double lng, string deviceId)
{
// Get all active geo-fences
var fences = await GetActiveFences();
foreach (var fence in fences)
{
bool inside = fence.Type switch
{
FenceType.Circular => IsInsideCircle(
lat, lng, fence.CenterLat, fence.CenterLng, fence.RadiusKm),
FenceType.Polygon => IsInsidePolygon(
lat, lng, fence.Boundary),
_ => false
};
string previousState = await GetFenceState(deviceId, fence.Id);
string currentState = inside ? "inside" : "outside";
if (previousState != currentState)
{
await UpdateFenceState(deviceId, fence.Id, currentState);
// Trigger event
await _kafka.ProduceAsync("geofence-events", deviceId,
new GeoFenceEvent
{
DeviceId = deviceId,
FenceId = fence.Id,
EventType = inside ? "entered" : "exited",
Latitude = lat,
Longitude = lng,
Timestamp = DateTime.UtcNow
});
}
}
return true;
}
private bool IsInsideCircle(
double lat, double lng, double centerLat, double centerLng, double radiusKm)
{
return DistanceCalculator.HaversineDistance(lat, lng, centerLat, centerLng) <= radiusKm;
}
private bool IsInsidePolygon(double lat, double lng, List<Coordinate> polygon)
{
// Ray casting algorithm
bool inside = false;
int n = polygon.Count;
for (int i = 0, j = n - 1; i < n; j = i++)
{
if ((polygon[i].Lat > lat) != (polygon[j].Lat > lat) &&
lng < (polygon[j].Lng - polygon[i].Lng) * (lat - polygon[i].Lat) /
(polygon[j].Lat - polygon[i].Lat) + polygon[i].Lng)
{
inside = !inside;
}
}
return inside;
}
}
Geo-Fence Performance Optimization
C#
public class OptimizedGeoFenceChecker
{
private readonly IDatabase _redis;
public async Task<List<string>> CheckAllFences(double lat, double lng)
{
// Step 1: Quick reject using bounding box
var nearbyFenceIds = await _redis.GeoSearchAsync(
"geofence:centers",
new GeoSearchBox(lng, lat, 10, GeoUnit.Kilometers),
count: 50);
// Step 2: Detailed check only for nearby fences
var triggered = new List<string>();
foreach (var fence in nearbyFenceIds)
{
if (await IsInsideFence(lat, lng, fence.MemberId))
{
triggered.Add(fence.MemberId);
}
}
return triggered;
}
}
15. Result Ranking & Relevance
Nearby search results must be ranked not just by distance, but by a combination of distance, popularity, relevance, and business factors. A highly-rated restaurant 2km away should rank above a poorly-rated one 1km away. The ranking algorithm must balance these signals while remaining fast enough for real-time queries.
Ranking Signals
| Signal | Weight | Source | Update Frequency |
|---|---|---|---|
| Distance | 35% | Haversine calculation | Real-time |
| Rating | 25% | User reviews | Daily |
| Review count | 15% | User reviews | Daily |
| Relevance (text match) | 15% | Search query vs place name/description | Real-time |
| Business factor (promoted, verified) | 10% | Business settings | On change |
C#
public class PlaceRanker
{
public List<ScoredPlace> RankResults(
List<Place> candidates, SearchRequest request)
{
return candidates
.Select(p => new ScoredPlace
{
Place = p,
Score = CalculateCompositeScore(p, request)
})
.OrderByDescending(s => s.Score)
.ToList();
}
private double CalculateCompositeScore(Place place, SearchRequest request)
{
double distanceScore = CalculateDistanceScore(
request.Lat, request.Lng, place.Latitude, place.Longitude, request.Radius);
double ratingScore = (place.Rating / 5.0) * 100;
double reviewScore = Math.Min(place.ReviewCount / 1000.0, 1.0) * 100;
double relevanceScore = CalculateTextRelevance(place.Name, request.Query);
double businessScore = CalculateBusinessScore(place);
return distanceScore * 0.35
+ ratingScore * 0.25
+ reviewScore * 0.15
+ relevanceScore * 0.15
+ businessScore * 0.10;
}
private double CalculateDistanceScore(
double lat1, double lng1, double lat2, double lng2, double maxRadius)
{
double distance = DistanceCalculator.HaversineDistance(lat1, lng1, lat2, lng2);
// Exponential decay: closer is much better
return Math.Exp(-distance / (maxRadius * 0.3)) * 100;
}
private double CalculateTextRelevance(string placeName, string? query)
{
if (string.IsNullOrEmpty(query)) return 50; // neutral score
string normalizedName = placeName.ToLower();
string normalizedQuery = query.ToLower();
if (normalizedName == normalizedQuery) return 100;
if (normalizedName.StartsWith(normalizedQuery)) return 90;
if (normalizedName.Contains(normalizedQuery)) return 75;
// Fuzzy match score
int editDistance = LevenshteinDistance(normalizedName, normalizedQuery);
return Math.Max(0, 60 - editDistance * 10);
}
private double CalculateBusinessScore(Place place)
{
double score = 50; // neutral
if (place.IsVerified) score += 20;
if (place.IsPromoted) score += 15;
if (place.HasPhotos) score += 10;
if (place.HasMenu) score += 5;
return Math.Min(score, 100);
}
}
16. Scaling the Geo Service
Sharding Strategy
Global (122 cells)"] H3L1["H3 Level 3
Regional (~800K cells)"] H3L2["H3 Level 7
Local (~42M cells)"] end H3L0 -->|"Route by region"| SHARD0[Shard: Americas] H3L0 -->|"Route by region"| SHARD1[Shard: EMEA] H3L0 -->|"Route by region"| SHARD2[Shard: APAC] SHARD0 -->|"Sub-shard by H3 L3"| LOCAL0[(Local DB)] SHARD1 -->|"Sub-shard by H3 L3"| LOCAL1[(Local DB)] SHARD2 -->|"Sub-shard by H3 L3"| LOCAL2[(Local DB)]
Scale Targets & Current Capacity
| Metric | Current | Target (2yr) | Scaling Approach |
|---|---|---|---|
| Places | 200M | 500M | Shard by H3 region + read replicas |
| QPS (searches) | 17K | 50K | Horizontal scaling + caching |
| QPS (location updates) | 42K | 150K | Redis Cluster + Kafka partitioning |
| Concurrent WebSockets | 5M | 20M | WebSocket fleet with sticky sessions |
| Storage | 2.4 TB | 8 TB | Partition by time + archival |
Horizontal Scaling Patterns
C#
// Geo-aware load balancer configuration
public class GeoAwareLoadBalancer
{
private readonly Dictionary<string, string[]> _regionEndpoints = new()
{
["NA"] = new[] { "us-east-1.internal:8080", "us-west-2.internal:8080" },
["EU"] = new[] { "eu-west-1.internal:8080", "eu-central-1.internal:8080" },
["APAC"] = new[] { "ap-southeast-1.internal:8080", "ap-northeast-1.internal:8080" }
};
public string RouteRequest(string clientIp, double lat, double lng)
{
string region = DetermineRegion(lat, lng);
var endpoints = _regionEndpoints[region];
// Round-robin within region
return endpoints[Interlocked.Increment(ref _counter) % endpoints.Length];
}
private string DetermineRegion(double lat, double lng)
{
if (lng >= -170 && lng <= -30) return "NA";
if (lng >= -30 && lng <= 60) return "EU";
return "APAC";
}
}
Database Partitioning
SQL
-- Partition places by geohash prefix (region-based)
CREATE TABLE places_americas PARTITION OF places
FOR VALUES FROM ('0') TO ('9');
CREATE TABLE places_europe_africa PARTITION OF places
FOR VALUES FROM ('u') TO ('z');
CREATE TABLE places_asia_oceania PARTITION OF places
FOR VALUES FROM ('s') TO ('t');
-- Partition reviews by month for TTL management
CREATE TABLE reviews_2025_01 PARTITION OF reviews
FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');
-- Auto-create future partitions
SELECT partman.create_parent(
p_parent_table := 'public.reviews',
p_control := 'created_at',
p_type := 'range',
p_interval := '1 month'
);
17. Data Consistency & Replication
Consistency Requirements by Data Type
| Data Type | Consistency Model | Replication Lag Tolerance | Conflict Resolution |
|---|---|---|---|
| Place details (write) | Strong (primary region) | 0 (synchronous) | Last-write-wins |
| Place details (read) | Eventual (cross-region) | < 5 seconds | Primary region wins |
| Driver locations | Eventual | 1-3 seconds | Latest timestamp wins |
| Reviews | Strong (primary write) | 0 (synchronous) | Primary region wins |
| Geo-fence states | Eventual | < 10 seconds | Event-driven reconciliation |
| Search index | Eventual | 15-30 seconds | Rebuild from source |
Cross-Region Replication
C#
public class CrossRegionReplicationService
{
private readonly IDatabase _primaryRedis;
private readonly IDatabase _replicaRedis;
private readonly IKafkaProducer _kafka;
public async Task ReplicatePlaceUpdate(PlaceUpdate update)
{
// 1. Write to primary database (synchronous)
await _repository.UpdatePlace(update);
// 2. Invalidate local cache
await _primaryRedis.KeyDeleteAsync($"place:{update.PlaceId}");
await _primaryRedis.GeoRemoveAsync("places:index", update.PlaceId);
await _primaryRedis.GeoAddAsync("places:index",
new GeoEntry(update.Longitude, update.Latitude, update.PlaceId));
// 3. Publish to Kafka for cross-region replication
await _kafka.ProduceAsync("place-updates", update.PlaceId, update);
// 4. Replicate to other regions (async via Kafka consumer)
// Each region has its own consumer that updates local Redis and cache
}
public async Task HandleCrossRegionUpdate(PlaceUpdate update)
{
// Update local Redis replica
await _replicaRedis.KeyDeleteAsync($"place:{update.PlaceId}");
await _replicaRedis.GeoRemoveAsync("places:index", update.PlaceId);
await _replicaRedis.GeoAddAsync("places:index",
new GeoEntry(update.Longitude, update.Latitude, update.PlaceId));
// Update local cache
await _cache.InvalidateAsync($"place:{update.PlaceId}");
}
}
18. Monitoring & Observability
Key Metrics
| Metric | Target | Alert Threshold |
|---|---|---|
| Search latency (p99) | < 100ms | > 200ms |
| Place detail latency (p99) | < 50ms | > 100ms |
| Location update ingestion rate | 42K QPS | < 30K QPS |
| WebSocket connections | 5M concurrent | > 80% capacity |
| Redis GEO memory | < 80% | > 90% |
| PostGIS query latency (p99) | < 50ms | > 100ms |
| Geo-fence check latency | < 20ms | > 50ms |
| Location data freshness | < 3 seconds | > 10 seconds |
| Cache hit rate (search) | > 70% | < 50% |
Geo-Specific Monitoring
PromQL
# Location update ingestion rate
sum(rate(location_updates_total[5m])) by (region)
# WebSocket connection count by region
sum(websocket_connections) by (region)
# Search latency heatmap
histogram_quantile(0.99,
rate(search_duration_seconds_bucket[5m])
)
# Redis GEO memory usage
redis_memory_used_bytes / redis_memory_max_bytes * 100
# Geo-fence check latency
histogram_quantile(0.99,
rate(geofence_check_duration_seconds_bucket[5m])
)
# Stale location count (no update in 30s)
count(time() - driver_last_update_timestamp > 30)
Alerting Rules
YAML
groups:
- name: proximity-service
rules:
- alert: HighSearchLatency
expr: histogram_quantile(0.99,
rate(search_duration_seconds_bucket[5m])) > 0.2
for: 5m
labels:
severity: critical
annotations:
summary: "Proximity search p99 latency exceeds 200ms"
- alert: StaleDriverLocations
expr: count(time() - driver_last_update_timestamp > 30) > 1000
for: 2m
labels:
severity: warning
annotations:
summary: "Over 1000 drivers with stale location data"
- alert: LowSearchCacheHitRate
expr: rate(search_cache_hits_total[5m]) /
rate(search_cache_lookups_total[5m]) * 100 < 50
for: 10m
labels:
severity: warning
annotations:
summary: "Search cache hit rate below 50%"
- alert: WebSocketConnectionSpike
expr: sum(websocket_connections) > 16000000
for: 5m
labels:
severity: warning
annotations:
summary: "WebSocket connections exceed 80% capacity"
SLO Definition
| SLO | Target | Error Budget (30 days) |
|---|---|---|
| Search availability | 99.99% | 4.32 minutes |
| Search latency (p99 < 100ms) | 99.9% | 43.2 minutes of slow queries |
| Location freshness (< 3s) | 99.9% | 43.2 minutes of stale data |
| WebSocket uptime | 99.95% | 21.6 minutes |
| Data durability | 99.999999% | 2.6 seconds of data loss |
19. Cost Estimation
Monthly Infrastructure Cost (200M places, 50M DAU)
| Component | Spec | Monthly Cost |
|---|---|---|
| Application servers | 30 × m5.xlarge (multi-region) | ~$8,400 |
| PostgreSQL + PostGIS | 4 shards × primary + 2 replicas (r5.2xlarge) | ~$27,600 |
| Redis GEO cluster | 12 nodes × r5.xlarge (multi-region) | ~$11,400 |
| Elasticsearch cluster | 9 nodes × m5.2xlarge | ~$8,600 |
| Kafka cluster | 9 nodes × m5.xlarge (multi-region) | ~$5,100 |
| WebSocket fleet | 20 × c5.xlarge | ~$4,200 |
| Load balancers (multi-region) | 6 ALBs | ~$600 |
| S3 (photos, backups) | ~50TB | ~$1,200 |
| CloudFront / CDN | 20TB/month egress | ~$1,700 |
| Monitoring (Datadog) | 30 hosts, custom metrics | ~$3,000 |
| Route 53 | DNS + health checks | ~$100 |
| Total | ~$71,900/month |
Cost Optimization Strategies
| Strategy | Savings | Trade-off |
|---|---|---|
| Reserved instances (1yr) | 30-40% | Upfront commitment |
| Graviton instances | 20% | ARM compatibility testing |
| Archive old reviews to S3 | 30% on DB storage | Slower historical queries |
| Compress Redis values | 40% on Redis memory | CPU overhead |
| Regional caching | 50% on DB reads | Eventual consistency |
Cost per Query Analysis
| Query Type | Infrastructure Cost | Revenue per Query | Margin |
|---|---|---|---|
| Nearby search | $0.00002 | $0.001 (ad impression) | 98% |
| Place detail | $0.00005 | $0.002 (click-through) | 97.5% |
| Location update | $0.000001 | $0.0005 (trip commission) | 99.8% |
| Directions request | $0.0001 | $0.005 (navigation ad) | 98% |
| Geo-fence check | $0.000005 | $0.003 (promotion click) | 99.8% |
Location-based services are inherently profitable because they combine high-volume, low-cost queries with high-value monetization opportunities. A single nearby search generates approximately $0.001 in ad revenue while costing only $0.00002 in infrastructure — a 50x return. The key to maintaining profitability at scale is aggressive caching (reducing database costs) and efficient spatial indexing (reducing compute costs). As the system scales from 17K to 50K QPS, these per-query economics become increasingly important because small inefficiencies compound into significant costs.
Capacity Planning Growth Model
| Year | Places | DAU | QPS (search) | Storage | Monthly Cost |
|---|---|---|---|---|---|
| Year 1 | 200M | 50M | 17K | 2.4 TB | $72K |
| Year 2 | 300M | 100M | 35K | 5 TB | $120K |
| Year 3 | 400M | 150M | 50K | 8 TB | $180K |
| Year 5 | 500M | 250M | 80K | 15 TB | $300K |
The growth model shows that costs scale sub-linearly with user growth because caching becomes more effective at higher volumes. The 80/20 rule applies strongly to nearby searches — 80% of searches come from 20% of locations (major cities). As the user base grows, the cache hit rate improves because more queries hit the same popular locations, reducing the need for database queries. This is why location-based services become more profitable as they scale — the marginal cost per query decreases while revenue per query remains constant.
Disaster Recovery for Location Services
Location services have unique disaster recovery requirements because they are time-sensitive and geographically distributed. A region-wide outage doesn't just affect availability — it affects the ability to track moving objects and serve location-dependent features. The recovery strategy must prioritize the most critical functions: real-time tracking for ride-hailing takes precedence over place search, which takes precedence over reviews and photos.
| Failure Scenario | Impact | Recovery Time | Mitigation |
|---|---|---|---|
| Redis cluster failure | Location tracking stops, search falls back to DB | 30 seconds (failover) | Multi-AZ Redis, read replicas |
| Primary database failure | No new place creation, reads from replica | 1-2 minutes | Streaming replication, auto-failover |
| Region-wide outage | All services in region down | 5-15 minutes (DNS failover) | Multi-region with GeoDNS |
| Kafka cluster failure | Location updates buffered, analytics delayed | 2-5 minutes | Multi-AZ Kafka, mirrored topics |
| Elasticsearch outage | Text search unavailable, nearby search still works | 5-10 minutes | Graceful degradation to PostGIS |
Graceful Degradation Strategy
C#
public class ResilientProximitySearch
{
private readonly IRedisGEO _redisGEO;
private readonly IPostGISRepository _postGIS;
private readonly IElasticsearchClient _es;
public async Task<SearchResult> SearchWithFallback(SearchRequest request)
{
try
{
// Primary: Redis GEO (fastest)
return await SearchWithRedis(request);
}
catch (RedisException)
{
try
{
// Fallback 1: PostGIS (slower but reliable)
return await SearchWithPostGIS(request);
}
catch (PostgresException)
{
// Fallback 2: Cached results from CDN
return await GetCachedResults(request) ?? SearchResult.Empty();
}
}
}
}
Key Takeaways for Production Location Services
- Redis GEO is the gold standard for real-time proximity queries — it handles millions of members with sub-millisecond latency and supports radius search, distance calculation, and geohash operations natively
- PostGIS with GiST indexes provides the most flexible and accurate spatial queries for complex use cases — polygon containment, multi-geometry support, and spatial joins
- The Haversine formula is sufficient for 99% of distance calculations — don't over-engineer with Vincenty unless you need sub-millimeter accuracy
- Geo-aware sharding is essential for global scale — route queries to regional databases to minimize cross-region latency
- Multi-stage search pipelines (geohash grid → distance filter → business filter → ranking) balance speed with accuracy
- Real-time location tracking requires a fundamentally different architecture than place search — separate the two concerns with different storage technologies
- Geo-fence optimization via bounding box pre-filter reduces the polygon containment check from O(fences) to O(nearby_fences), which is the difference between checking 10,000 fences and checking 50 fences on every location update
- The 80/20 rule makes location services more profitable at scale — popular locations get cached, reducing marginal cost per query
- Implement a Kalman filter for GPS smoothing — raw GPS data is noisy and will cause false geo-fence triggers without filtering
- Use adaptive update frequency on mobile clients — 3 seconds when moving, 30 seconds when stationary — to balance accuracy with battery life
- Test edge cases: International Date Line, polar regions, GPS drift in urban canyons, and high-density areas like Times Square
- Always query all 8 neighboring geohash cells to avoid missing places on cell boundaries
- Cache search results with geohash-based keys for spatial locality in the cache
- Use Redis GEO for real-time driver tracking and PostGIS for durable place storage — they solve different problems
Whether you are building a simple store locator, a ride-hailing platform, or a global mapping service, the fundamental principles remain the same: use the right spatial index for your access pattern, separate real-time data from durable data, and always pre-filter with bounding boxes before applying expensive distance calculations. Master these principles and you can build any location-based system, from a neighborhood dog-walking app to a global logistics platform tracking millions of vehicles across continents. The combination of Redis GEO for speed, PostGIS for accuracy, and Elasticsearch for text relevance gives you the tools to build any location-based feature your users need, at any scale.
20. Case Studies — Production Systems
Uber's Location System
| Component | Details |
|---|---|
| Spatial index | H3 (Uber's hexagonal hierarchical spatial index) |
| Location updates | 19M trips/day, position updates every 4 seconds |
| Storage | Schemaless (custom distributed store) + Redis |
| Matching | Geohash-based partitioning with demand/supply balancing |
| ETA prediction | ML model on road graph + real-time traffic |
| Scale | 19M trips/day across 70+ countries |
Google Maps Platform
| Component | Details |
|---|---|
| Places database | 200M+ places, 25M updates/day |
| Spatial index | S2 Geometry + custom hierarchical tiles |
| Real-time data | Live traffic from Android phones + GPS probes |
| Search | Combined text + location ranking with ML |
| Cache | Global CDN with tile-based caching |
| API | Places API, Maps JavaScript API, Directions API |
Yelp's Proximity Search
| Component | Details |
|---|---|
| Places database | 200M+ businesses, 38M monthly visitors |
| Spatial index | Custom geohash-based sharding |
| Search | Elasticsearch with geo_point + text relevance |
| Cache | Memcached + Redis with L1 client cache |
| Reviews | 200M+ reviews, real-time feed |
| Recommendations | ML-based personalization with location features |
Architecture Trade-offs in Production
Each production system makes different trade-offs based on their specific requirements. Uber prioritizes real-time driver tracking and matching accuracy over place data completeness. Google Maps prioritizes global coverage and tile-based rendering over real-time updates. Yelp prioritizes review quality and search relevance over real-time location tracking. These trade-offs drive the choice of spatial index, storage engine, caching strategy, and API design. Understanding these trade-offs is essential for making informed architecture decisions in your own location-based system.
The key lesson from these case studies is that no single spatial index or storage technology is sufficient for a production location service. Real-world systems combine multiple technologies — each optimized for a specific access pattern — to achieve the performance, accuracy, and scale required. The challenge is keeping these multiple indexes in sync as data changes, which requires a robust event-driven architecture with idempotent updates and reconciliation processes.
21. Edge Cases
| Edge Case | Impact | Solution |
|---|---|---|
| International Date Line crossing | Points near ±180° longitude may wrap around | Normalize coordinates, handle wrap-around in distance calc |
| Pole locations (±90° latitude) | Geohash distortion at poles | Use S2 or H3 which handle spherical geometry |
| GPS drift (urban canyons) | Inaccurate positions, false geo-fence triggers | Apply Kalman filter, increase geo-fence buffer zone |
| Zero-radius search | No results for exact coordinate match | Default to 1km minimum radius |
| Duplicate places | Same restaurant listed twice | Deduplication by name + location proximity |
| High-density areas (Times Square) | 1000+ places in 100m radius | Cap results, implement pagination, use ranking |
| No results found | Empty response for remote areas | Expand radius progressively, suggest alternatives |
| Location spoofing | Fraudulent location updates | Server-side validation, GPS + cell tower cross-check |
| Mass migration (sports events) | Sudden demand spike in one area | Auto-scaling + cache warming for event venues |
| Time zone boundary issues | Opening hours calculation errors | Store hours in UTC, convert to local time for display |
| Multi-floor buildings | Same lat/lng, different floors | Add optional altitude field, floor-level filtering |
| Country border proximity | Nearby places in different countries | Filter by country parameter, handle multi-currency |
| Rapidly moving user (car/train) | Location changes faster than updates | Interpolate between updates, use speed-based prediction |
| Geohash cell boundary cases | Nearest place is in adjacent cell | Always query 8 neighboring geohash cells |
Kalman Filter for GPS Smoothing
C#
public class KalmanFilter
{
private double _estimatedLat;
private double _estimatedLng;
private double _errorCovariance = 1.0;
private const double ProcessNoise = 0.001;
private bool _initialized;
public (double lat, double lng) Filter(double measuredLat, double measuredLng, double accuracy)
{
if (!_initialized)
{
_estimatedLat = measuredLat;
_estimatedLng = measuredLng;
_initialized = true;
return (_estimatedLat, _estimatedLng);
}
// Measurement noise based on GPS accuracy
double measurementNoise = accuracy * accuracy / 1000.0;
// Kalman gain
double kalmanGain = _errorCovariance / (_errorCovariance + measurementNoise);
// Update estimates
_estimatedLat += kalmanGain * (measuredLat - _estimatedLat);
_estimatedLng += kalmanGain * (measuredLng - _estimatedLng);
// Update error covariance
_errorCovariance = (1 - kalmanGain) * _errorCovariance + ProcessNoise;
return (_estimatedLat, _estimatedLng);
}
}
22. Interview Q&A
Use a multi-stage approach: (1) Encode the center point as a geohash and compute the geohash prefixes for the 8 neighboring cells at the appropriate precision level for the radius. (2) Query Redis GEO or PostGIS for candidates in those geohash cells. (3) Apply exact Haversine distance filtering on the candidates. This avoids scanning the entire database and reduces the problem to a few hundred candidates for typical city-scale queries.
Use Redis GEO for the spatial index (it supports millions of members with sub-millisecond queries). Drivers push location updates via HTTP POST every 3 seconds, which are written to Redis and published to Kafka. Riders subscribe via WebSocket and receive location updates in real-time. Use Redis pub/sub for fan-out to interested viewers. For 5M drivers, the Redis GEO structure needs approximately 400MB of memory — well within a single Redis instance.
Geohash uses a Z-order curve to encode lat/lng into a string, producing rectangular cells that distort at poles. S2 (Google) uses spherical geometry to project cells onto the Earth's surface, providing uniform accuracy globally. H3 (Uber) uses a hexagonal grid that provides more uniform cell sizes and equal-distance neighbors. For most applications, geohash is simplest to implement. Use S2 for global mapping (Google Maps) and H3 for ride-hailing (Uber) where uniform hex cells matter for demand/supply balancing.
Don't check every device against every fence. Instead: (1) Store fence centers in Redis GEO. (2) For each device location update, use GEOSEARCH to find fences within a generous radius (10km). (3) Only perform the expensive polygon containment check for those nearby fences. (4) Cache the previous inside/outside state per device-fence pair to detect transitions. This reduces the problem from O(devices × fences) to O(devices × nearby_fences).
Scale across three dimensions: (1) Caching — 70%+ of nearby queries are repeatable (same location, same radius). Cache results in Redis with geohash-based keys and 5-minute TTL. (2) Horizontal scaling — shard the place database by geohash prefix and route queries to the appropriate shard. (3) Read replicas — distribute read queries across multiple PostgreSQL replicas. At 50K QPS, with 70% cache hits, you only need 15K QPS to the database, which is manageable with 4-6 read replicas.
Use a machine learning model trained on click-through data. Features include: distance (most important), rating, review count, text relevance (query match), user preferences (past visits, cuisine preferences), time of day (restaurants open now), and business factors (verified, promoted). Start with a simple weighted linear model (distance 35%, rating 25%, reviews 15%, text 15%, business 10%) and evolve to a learned model as you collect click data.
For the Date Line: when computing bounding boxes, check if the longitude range wraps around ±180°. If it does, split the query into two ranges (e.g., 179° to 180° and -180° to -179°) and merge results. For poles: geohash distortion is extreme near ±90° latitude. Use S2 or H3 which handle spherical geometry natively, or apply a latitude-dependent correction factor to geohash cell sizes.
Multi-layer defense: (1) GPS accuracy check — reject updates with accuracy > 100 meters. (2) Speed validation — if a driver's position changes by more than 200km/h between updates, flag as suspicious. (3) Cell tower cross-validation — compare GPS position with cell tower location. (4) Device attestation — use platform attestation (SafetyNet on Android, DeviceCheck on iOS) to verify the app hasn't been tampered with. (5) Server-side ML model that detects anomalous movement patterns.
System Design Framework
| Step | Proximity Service Approach |
|---|---|
| Requirements | 200M places, 50M DAU, 100ms search latency |
| Back-of-envelope | 17K QPS searches, 42K QPS location updates, 2.4TB storage |
| Data model | Places with lat/lng, reviews, user locations |
| API design | GET nearby, GET details, POST location update, WebSocket tracking |
| Architecture | Redis GEO + PostGIS + Elasticsearch + Kafka |
| Deep dive | Quadtree/geohash indexing, distance algorithms, ranking |
| Reliability | Multi-region with geo-aware routing, cache layers |
23. Conclusion
Building a proximity service at scale requires mastering geospatial algorithms, spatial indexing, and distributed systems. The key insight is that spatial queries are fundamentally different from traditional database queries — you need specialized data structures (quadtrees, geohashes, S2/H3) to efficiently partition and search 2D space. Redis GEO provides the fastest proximity queries (sub-millisecond) for real-time use cases, while PostGIS offers the most flexibility for complex spatial queries. Elasticsearch bridges the gap between text search and location search.
The architecture of a modern location service is built on the separation of concerns between fast-changing real-time data (driver positions, traffic) and slow-changing reference data (place details, reviews). Redis handles the real-time layer with its in-memory GEO commands, while PostgreSQL with PostGIS provides the durable, queryable layer for place data. Kafka connects these layers through an event-driven architecture that allows each component to scale independently. The result is a system that can serve 50,000 search queries per second with sub-100ms latency while simultaneously tracking millions of moving objects in real-time.
The choice of spatial index is the most critical architectural decision. For most production systems, a hybrid approach works best: use Redis GEO for the hottest real-time queries (driver tracking, immediate nearby search), PostGIS for the full place database with complex filtering, and Elasticsearch for text-based search combined with geographic filtering. This triple-index approach provides the performance of specialized indexes while maintaining the flexibility to evolve the system as requirements change. The geohash encoding ties everything together, providing a common spatial key that works across all three storage systems.
Key Numbers to Remember
| Metric | Value |
|---|---|
| Haversine accuracy | ±0.5% of true distance |
| Geohash precision 7 | ~153m × 153m cell |
| Redis GEO query time | < 1ms for 1M members |
| PostGIS GiST index | 10-50ms for 200M rows |
| Location update frequency | Every 3 seconds (moving) |
| WebSocket fan-out | 1M concurrent connections per server |
| H3 Level 7 cell | ~5.16 km² area |
| Vincenty accuracy | ±0.5mm (overkill for most apps) |
Production Checklist
- Choose spatial index: Redis GEO for real-time, PostGIS for persistence, Elasticsearch for text + geo
- Implement Haversine for distance calculations (sufficient for 99% of use cases)
- Multi-stage search pipeline: geohash grid → distance filter → business filter → ranking
- Real-time tracking via Redis GEO + Kafka + WebSocket fan-out
- Geo-fence optimization: bounding box pre-filter before polygon containment check
- Multi-level cache: client → CDN → Redis → database with appropriate TTLs
- Geo-aware sharding: route queries to regional databases based on client location
- Ranking algorithm: distance-weighted composite score, evolve to ML-based
- Monitor search latency, cache hit rate, location freshness, and WebSocket connections
- Handle edge cases: Date Line, poles, GPS drift, high-density areas
Common Interview Mistakes to Avoid
- Using Euclidean distance on lat/lng without accounting for Earth's curvature
- Ignoring the pole distortion problem with geohash-based systems
- Making geo-fence checks against all fences for every location update
- Not separating real-time location data (Redis) from durable place data (PostgreSQL)
- Forgetting that nearby search results need both spatial proximity AND relevance ranking
- Skip discussing the trade-off between exact (PostGIS) and approximate (geohash) spatial queries
- Ignoring multi-region deployment for a service that must respond to global users in <100ms
- Not addressing how to handle GPS drift and inaccurate location data from mobile devices
Geospatial Data Pipeline Architecture
The complete data pipeline for a location service involves multiple stages of ingestion, processing, indexing, and serving. Understanding this pipeline end-to-end is critical for debugging performance issues and designing reliable systems. The pipeline starts with raw location data from mobile devices and place data from business owners, flows through validation and enrichment stages, and ends with indexed, cached, and served data that powers the user-facing search experience.
Primary Data)] REDIS[(Redis GEO
Hot Data + Real-Time)] ES[(Elasticsearch
Full-Text + Geo)] S3[(S3
Photos + Archives)] end subgraph Serving["Query Serving"] SEARCH[Search Service] DETAIL[Detail Service] TRACK[Tracking Service] end MOB & WEB & API & BATCH --> KAFKA KAFKA --> VALIDATE --> ENRICH --> DEDUP DEDUP --> PG & REDIS & ES PG --> SEARCH & DETAIL REDIS --> SEARCH & TRACK ES --> SEARCH
Data Enrichment Pipeline
C#
public class PlaceEnrichmentPipeline
{
private readonly IGeocodingService _geocoding;
private readonly ICategoryClassifier _classifier;
private readonly IPhotoAnalyzer _photoAnalyzer;
private readonly IOpeningHoursParser _hoursParser;
public async Task<EnrichedPlace> Enrich(RawPlace raw)
{
var enriched = new EnrichedPlace
{
Id = raw.Id,
Name = raw.Name,
Latitude = raw.Latitude,
Longitude = raw.Longitude,
};
// Geocode address if coordinates are missing
if (raw.Latitude == 0 && raw.Longitude == 0 && !string.IsNullOrEmpty(raw.Address))
{
var coords = await _geocoding.GeocodeAsync(raw.Address);
enriched.Latitude = coords.Latitude;
enriched.Longitude = coords.Longitude;
}
// Generate geohash
enriched.Geohash = GeohashEncoder.Encode(
enriched.Latitude, enriched.Longitude, 9);
// Auto-classify category
if (string.IsNullOrEmpty(raw.Category))
{
enriched.Category = await _classifier.ClassifyAsync(
raw.Name, raw.Description);
}
else
{
enriched.Category = raw.Category;
}
// Parse opening hours from text
if (raw.OpeningHoursText != null)
{
enriched.OpeningHours = await _hoursParser.ParseAsync(
raw.OpeningHoursText);
}
// Analyze photos for content
if (raw.PhotoUrls?.Any() == true)
{
enriched.HasPhotos = true;
enriched.PhotoTags = await _photoAnalyzer.AnalyzeBatchAsync(
raw.PhotoUrls.Take(5));
}
return enriched;
}
}
Place Deduplication Strategy
| Signal | Match Threshold | Weight |
|---|---|---|
| Name similarity (Levenshtein) | < 3 edits | 40% |
| Address similarity | Exact match | 30% |
| Phone number match | Exact match | 15% |
| Location proximity | < 100 meters | 10% |
| Category match | Same category | 5% |
Mobile SDK Architecture
C#
// Simplified mobile location SDK
public class LocationSDK
{
private readonly ILocationProvider _gps;
private readonly HttpClient _http;
private readonly BatteryOptimizationConfig _config;
public async Task StartTracking(TrackingConfig config)
{
// Adaptive update frequency based on movement
_gps.LocationChanged += async (sender, location) =>
{
// Determine update frequency based on context
var interval = DetermineUpdateInterval(location);
if (ShouldSendUpdate(location))
{
var update = new LocationUpdate
{
Latitude = location.Latitude,
Longitude = location.Longitude,
Accuracy = location.Accuracy,
Speed = location.Speed,
Heading = location.Heading,
BatteryLevel = _config.CurrentBatteryLevel
};
await _http.PostAsJsonAsync("/api/v1/locations/update", update);
}
};
}
private TimeSpan DetermineUpdateInterval(Location location)
{
// Adaptive frequency: faster when moving, slower when stationary
if (location.Speed > 5) return TimeSpan.FromSeconds(3); // Moving
if (location.Speed > 0.5) return TimeSpan.FromSeconds(10); // Walking
return TimeSpan.FromSeconds(30); // Stationary
}
private bool ShouldSendUpdate(Location location)
{
// Throttle updates if nothing changed significantly
if (_lastUpdate != null)
{
double distMoved = DistanceCalculator.HaversineDistance(
_lastUpdate.Latitude, _lastUpdate.Longitude,
location.Latitude, location.Longitude);
if (distMoved < 0.01) return false; // Less than 10 meters
}
return true;
}
}
Real-World Scale Comparisons
| Platform | Places | Daily Searches | Location Updates/sec | Architecture |
|---|---|---|---|---|
| Google Maps | 200M+ | 5B | 100K+ | S2 tiles + Bigtable + Spanner |
| Uber | N/A (drivers) | 19M trips | 50K | H3 + Schemaless + Redis |
| Yelp | 200M+ | 38M/month | N/A | Geohash + Elasticsearch + Memcached |
| Foursquare | 105M+ | 10M/day | N/A | S2 + custom spatial DB |
| DoorDash | 500K+ | 1M+/day | 10K+ | Geohash + PostGIS + Redis |
| Pokémon GO | N/A (S2 cells) | 100M+ active | 1M+ | S2 cells + custom backend |
24. Geofencing Engine and Real-Time Zone Monitoring
Geofencing enables location-triggered actions when users enter or exit defined geographic boundaries. A production geofencing engine must evaluate thousands of geofences per location update with sub-10ms latency, handle polygon-based and circular zones efficiently, and process entry/exit events without duplicate firings. This is critical for ride-sharing driver positioning, delivery radius management, and location-based notifications.
public class GeofencingEngine
{
private readonly SpatialIndex<Geofence> _spatialIndex;
private readonly IDistributedCache _stateCache;
public async Task<List<GeofenceEvent>> EvaluateLocationAsync(
string userId, double latitude, double longitude)
{
var point = new GeoPoint(latitude, longitude);
var events = new List<GeofenceEvent>();
// Find all geofences that contain or are near this point
var candidateFences = _spatialIndex.Query(point, bufferMeters: 100);
// Get previous state for this user
var previousState = await _stateCache.GetAsync<UserGeofenceState>(
$"geofence:state:{userId}");
var currentInside = new HashSet<string>();
var newlyEntered = new HashSet<string>();
var newlyExited = new HashSet<string>();
foreach (var fence in candidateFences)
{
bool isInside = fence.Type == GeofenceType.Circle
? IsInsideCircle(point, fence.Center, fence.RadiusMeters)
: IsInsidePolygon(point, fence.Polygon);
currentInside.Add(fence.Id);
if (isInside && (previousState == null || !previousState.InsideGeofences.Contains(fence.Id)))
{
newlyEntered.Add(fence.Id);
events.Add(new GeofenceEvent
{
UserId = userId,
GeofenceId = fence.Id,
EventType = GeofenceEventType.Entry,
Timestamp = DateTime.UtcNow,
Location = point,
GeofenceName = fence.Name
});
}
else if (!isInside && previousState?.InsideGeofences.Contains(fence.Id) == true)
{
newlyExited.Add(fence.Id);
events.Add(new GeofenceEvent
{
UserId = userId,
GeofenceId = fence.Id,
EventType = GeofenceEventType.Exit,
Timestamp = DateTime.UtcNow,
Location = point,
GeofenceName = fence.Name
});
}
}
// Check for exits from fences no longer in candidates
if (previousState != null)
{
foreach (var prevFenceId in previousState.InsideGeofences)
{
if (!currentInside.Contains(prevFenceId))
{
events.Add(new GeofenceEvent
{
UserId = userId,
GeofenceId = prevFenceId,
EventType = GeofenceEventType.Exit,
Timestamp = DateTime.UtcNow,
Location = point
});
}
}
}
// Update state cache
await _stateCache.SetAsync(
$"geofence:state:{userId}",
new UserGeofenceState
{
InsideGeofences = currentInside,
LastLocation = point,
LastUpdate = DateTime.UtcNow
},
new DistributedCacheEntryOptions
{
SlidingExpiration = TimeSpan.FromHours(1)
});
return events;
}
}
Geofencing Performance Metrics
| Metric | Target | Description |
|---|---|---|
| Evaluation Latency | < 5ms | Time to evaluate all geofences for one location update |
| Active Geofences | 100K+ | Number of simultaneously active geofence boundaries |
| Location Updates/sec | 100K+ | Location updates processed per second |
| Entry/Exit Accuracy | 99.9% | Correct detection of zone transitions |
| False Trigger Rate | < 0.1% | Entry/exit events that didn't actually occur |
| State Cache Hit Rate | > 95% | Previous state found in cache vs database |
Location Privacy and GDPR Compliance
Location data is among the most sensitive personal information. Systems must implement privacy-by-design principles: data minimization (collect only what's needed), purpose limitation (use location only for stated purpose), anonymization (strip identifiable links from stored locations), and user consent management. GDPR requires the right to erasure, data portability, and explicit consent before processing location data.
public class LocationPrivacyManager
{
private readonly ILocationStore _store;
private readonly IConsentManager _consent;
public async Task RecordLocationAsync(
string userId, GeoPoint location, Purpose purpose)
{
// Check consent before recording
var hasConsent = await _consent.HasConsentAsync(
userId, purpose);
if (!hasConsent)
throw new ConsentRequiredException(
$"User {userId} has not consented for {purpose}");
// Apply privacy-preserving transformations
var anonymized = AnonymizeLocation(location);
var rounded = RoundToPrecision(anonymized,
PrecisionLevel.City); // Default: city-level precision
await _store.StoreAsync(new LocationRecord
{
UserId = userId,
Location = rounded,
Purpose = purpose,
RecordedAt = DateTimeOffset.UtcNow,
RetentionExpiry = DateTimeOffset.UtcNow
.AddDays(GetRetentionDays(purpose))
});
}
public async Task<DeletionResult> EraseUserDataAsync(
string userId)
{
// GDPR right to erasure
var deleted = await _store.DeleteAllForUserAsync(userId);
return new DeletionResult
{
RecordsDeleted = deleted,
ErasedAt = DateTimeOffset.UtcNow
};
}
private GeoPoint AnonymizeLocation(GeoPoint loc)
{
// Add differential privacy noise
var noise = new Random().NextDouble() * 0.001;
return new GeoPoint(
loc.Latitude + noise,
loc.Longitude - noise);
}
}
Privacy Compliance Matrix
| Requirement | Implementation | Retention |
|---|---|---|
| Consent Management | Opt-in per purpose, granular controls | Until revoked |
| Data Minimization | City-level by default, precise only when needed | Active session only |
| Right to Erasure | Async deletion pipeline, 30-day SLA | Immediate purge |
| Data Portability | Export as GeoJSON on user request | 90 days on export |
| Anonymization | k-anonymity with k=50 for analytics | Indefinite (anonymized) |