system-design47 min read

How to Design a Proximity / Location-Based Service — A Senior+ Guide | Ayodhyya

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

Senior+ Guide 50+ min read 10,000+ words Ayodhyya

Table of Contents

  1. Introduction — The Location-Based Service Landscape
  2. Functional & Non-Functional Requirements
  3. Capacity Estimation & Back-of-Envelope
  4. Data Model & Storage
  5. API Design
  6. Geospatial Indexing Deep Dive
  7. High-Level Architecture
  8. Quadtree & Spatial Partitioning
  9. Geohash & S2 Geometry
  10. Distance Calculation Algorithms
  11. Proximity Search Implementation
  12. Real-Time Location Tracking
  13. Caching Strategy for Geo Data
  14. Geo-Fencing & Notifications
  15. Result Ranking & Relevance
  16. Scaling the Geo Service
  17. Data Consistency & Replication
  18. Monitoring & Observability
  19. Cost Estimation
  20. Case Studies — Production Systems
  21. Edge Cases
  22. Interview Q&A
  23. 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.

Interview Context: The proximity service design question tests your understanding of geospatial algorithms, spatial indexing, distributed caching, and real-time systems. It is a frequent question at Google, Uber, Lyft, DoorDash, and other location-aware companies. Understanding this design prepares you for any system that involves geographic data.

2. Functional & Non-Functional Requirements

Functional Requirements

#RequirementPriorityDetails
F1Add/update/delete placesMustBusiness owners can manage their listings
F2Nearby searchMustFind places within a radius of user location
F3Text searchMustSearch places by name, category, keyword
F4Place detailsMustGet full info: address, hours, photos, reviews
F5Distance calculationMustCompute walking/driving distance between two points
F6Real-time locationShouldTrack driver/rider positions in real-time
F7Geo-fencingShouldTrigger events when devices enter/exit areas
F8Directions/routingShouldTurn-by-turn navigation between two points
F9Reviews & ratingsNiceUser-generated reviews with star ratings
F10Traffic dataNiceReal-time traffic conditions on road segments

Non-Functional Requirements

RequirementTargetRationale
Search latency< 100ms (p99)Users expect instant nearby results
Availability99.99%Location services are critical for ride-hailing
Location accuracy10 meters (GPS)Accurate enough for nearby search
Update propagation< 5 secondsNew places should appear quickly
Scale200M places, 50M DAUGlobal scale for major platforms
Real-time tracking1 second updatesDriver position updates every second

3. Capacity Estimation & Back-of-Envelope

Daily Volume Estimates

MetricCalculationResult
Places in databaseGlobal POI count200 million
Daily active usersGiven50 million
Searches per user per dayGiven10
Total searches per day50M × 10500 million
Average QPS (searches)500M / 86,400~5,787 QPS
Peak QPS (3x)5,787 × 3~17,361 QPS
Place updates per day1% of 200M modified2 million
Real-time location updates1M drivers × 3600/hr3.6 billion/day
Location update QPS3.6B / 86,400~41,667 QPS

Storage Estimates

DataSize per RecordCountTotal
Place data~2 KB200M~400 GB
Place metadata (hours, photos refs)~5 KB200M~1 TB
Reviews~500 bytes2B~1 TB
Location snapshots (real-time)~100 bytes1M active~100 MB
Geo index (quadtree nodes)~50 bytes per node500M nodes~25 GB
Geohash index~20 bytes200M~4 GB
Total (hot data)~2.4 TB

Network Bandwidth

OperationRequests/dayAvg ResponseDaily Bandwidth
Nearby search500M10 KB~5 TB
Place details200M50 KB~10 TB
Location updates (ingest)3.6B100 bytes~360 GB
Location broadcasts (egress)10B200 bytes~2 TB
Total~17.5 TB/day

4. Data Model & Storage

Entity Relationship

erDiagram PLACE { bigint id PK varchar name varchar category text description float latitude float longitude varchar geohash varchar address varchar phone varchar website float rating int review_count jsonb opening_hours boolean is_active datetime created_at datetime updated_at } REVIEW { bigint id PK bigint place_id FK bigint user_id FK int rating text content datetime created_at } USER_LOCATION { bigint user_id PK float latitude float longitude float accuracy datetime updated_at } GEOFENCE { bigint id PK varchar name jsonb boundary varchar event_type boolean is_active } PLACE ||--o{ REVIEW : "has reviews" USER_LOCATION ||--o| PLACE : "nearby"

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.

flowchart TB subgraph Approaches["Geospatial Indexing Approaches"] direction TB QT[Quadtree
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 TypeQuery TimeBuild TimeMemoryAccuracy
B-tree (lat) + B-tree (lng)500ms+FastLowExact
PostGIS R-tree (GiST)10-50msMediumMediumExact
Quadtree (in-memory)0.1-1msSlowHighExact
Geohash prefix1-5msFastLowApproximate
S2 cells0.5-2msFastLowApproximate
H3 hexagons0.5-2msFastLowApproximate

7. High-Level Architecture

flowchart TB subgraph Clients MOBILE[Mobile App] WEB[Web App] API[API Client] end subgraph Gateway["API Gateway"] LB[Load Balancer] RL[Rate Limiter] end subgraph Services SEARCH[Place Search Service] DETAIL[Place Detail Service] TRACK[Location Tracking Service] FENCE[Geo-Fence Service] INDEX[Index Service] end subgraph Storage PG[(PostgreSQL + PostGIS)] REDIS[(Redis GEO)] ES[(Elasticsearch)] S3[(S3: Photos)] KAFKA[Kafka] end MOBILE & WEB & API --> LB LB --> RL RL --> SEARCH & DETAIL & TRACK & FENCE SEARCH --> PG & REDIS & ES DETAIL --> PG & S3 TRACK --> REDIS & KAFKA FENCE --> REDIS INDEX --> PG INDEX -.->|"Reindex"| ES

Component Responsibilities

ComponentResponsibilityTechnology
Place Search ServiceNearby search, text search, filteringPostGIS + Redis GEO
Place Detail ServiceFull place info, reviews, photosPostgreSQL + S3
Location Tracking ServiceReal-time driver/rider positionsRedis GEO + Kafka
Geo-Fence ServiceEnter/exit detection for regionsRedis + PostGIS
Index ServiceBuild and maintain spatial indexesQuadtree (in-memory) + ES
Redis GEOFast proximity queries, real-time dataRedis Cluster with GEO
PostGISDurable place storage, complex queriesPostgreSQL + PostGIS extension
ElasticsearchFull-text search, autocompleteES 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

PropertyValueImpact
Tree depth (200M points)15 levelsFast traversal
Max points per leaf50Balanced query performance
Memory per node~100 bytesTotal: ~500M nodes = ~50GB
Query complexityO(log n) averageMillions of operations/sec
Insert complexityO(log n) averageBatch rebuild: minutes
flowchart TB ROOT["World Bounds
(-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
Quadtree Limitation: Quadtrees are excellent for in-memory use but expensive to persist to disk. For durable storage, combine a quadtree index in the application layer with PostGIS GiST indexes in the database. The quadtree provides fast in-memory lookups for hot data, while PostGIS handles persistence and complex queries.

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

PrecisionCell SizeExampleUse Case
15,000 km × 5,000 kmsContinental routing
21,250 km × 625 kmstCountry-level
3156 km × 156 kmstuState/province
439 km × 19 kmstuwMetro area
54.9 km × 4.9 kmstuwuCity neighborhood
61.2 km × 609 mstuwupStreet level
7153 m × 153 mstuwupqBlock level
838 m × 19 mstuwupqnBuilding level
94.8 m × 4.8 mstuwupqnjPrecise 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

PropertyGeohashS2 GeometryH3
Cell shapeRectangleIrregular quadHexagon
Edge distortionHigh at polesLow (spherical)Very low
Max level123015
LibraryManual or RedisGoogle S2Uber H3
Neighboring cells8 (Moore)6 (variable)6 (uniform)
Best forSimple lookupsGlobal mappingRide-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

AlgorithmAccuracySpeedBest For
Haversine±0.5%FastMost proximity searches
Euclidean (flat)±1% (near equator)FastestSorting, filtering
Vincenty±0.5mmSlow (iterative)Precise surveying
Fast approximation±2%Very fastPre-filtering, ranking
ManhattanApproximateFastGrid-based cities

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.

flowchart TB subgraph Producer["Location Producers"] D1[Driver App 1] D2[Driver App 2] DN[Driver App N] end subgraph Ingestion["Location Ingestion"] LB[Load Balancer] API[Location Update API] VALIDATE[Validator] end subgraph Processing["Location Processing"] KAFKA[Kafka: location-updates] GEOREDIS[(Redis GEO: drivers)] GEOWORKER[Geo Worker] end subgraph Consumer["Location Consumers"] SUB[Subscription Service] WS1[Rider WebSocket 1] WS2[Rider WebSocket 2] WSN[Rider WebSocket N] end D1 & D2 & DN --> LB LB --> API API --> VALIDATE --> KAFKA KAFKA --> GEOREDIS KAFKA --> GEOWORKER GEOREDIS --> SUB SUB --> WS1 & WS2 & WSN

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

ComponentUpdate FrequencyProtocolBattery Impact
Driver app (moving)Every 3 secondsHTTP POST (batched)Medium (GPS + network)
Driver app (idle)Every 30 secondsHTTP POSTLow
Rider app (watching)N/A (receives via WS)WebSocketLow
Fleet managerEvery 5 secondsHTTP POSTMedium

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

LevelLocationTTLHit RateData
L1: ClientMobile app memory30 sec40%Recent search results, place details
L2: CDNEdge nodes5 min30%Place details, photos
L3: RedisApplication layer5 min20%Nearby search results, driver locations
L4: DatabasePostgreSQLN/A10%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 Invalidation for Geo Data: Place data changes infrequently (hours, ratings), but location data changes constantly (every 3 seconds for drivers). Use different TTL strategies: long TTL (5-30 minutes) for place details, very short TTL (3-10 seconds) for driver locations. For place updates, use write-through caching to invalidate the cache immediately on write.

Cache Warming Strategy

ScenarioStrategyTrigger
Cold startPre-warm top 100K places per cityDeployment
New areaLazy load on first queryCache miss
Trending locationPredictive pre-warmingEvent detection (sports, concerts)
Driver influxBatch pre-load driver positionsRush 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

TypeShapeAlgorithmUse Case
CircularCenter + radiusHaversine distanceSimple proximity alerts
PolygonCustom boundaryRay casting / winding numberDelivery zones, city limits
CorridorPath + widthPoint-to-line distanceRoute monitoring
Cell-basedH3/Geohash cellsCell membershipLarge-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

SignalWeightSourceUpdate Frequency
Distance35%Haversine calculationReal-time
Rating25%User reviewsDaily
Review count15%User reviewsDaily
Relevance (text match)15%Search query vs place name/descriptionReal-time
Business factor (promoted, verified)10%Business settingsOn 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

flowchart TB subgraph Sharding["Geo-Aware Sharding"] direction LR H3L0["H3 Level 0
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

MetricCurrentTarget (2yr)Scaling Approach
Places200M500MShard by H3 region + read replicas
QPS (searches)17K50KHorizontal scaling + caching
QPS (location updates)42K150KRedis Cluster + Kafka partitioning
Concurrent WebSockets5M20MWebSocket fleet with sticky sessions
Storage2.4 TB8 TBPartition 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 TypeConsistency ModelReplication Lag ToleranceConflict Resolution
Place details (write)Strong (primary region)0 (synchronous)Last-write-wins
Place details (read)Eventual (cross-region)< 5 secondsPrimary region wins
Driver locationsEventual1-3 secondsLatest timestamp wins
ReviewsStrong (primary write)0 (synchronous)Primary region wins
Geo-fence statesEventual< 10 secondsEvent-driven reconciliation
Search indexEventual15-30 secondsRebuild 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

MetricTargetAlert Threshold
Search latency (p99)< 100ms> 200ms
Place detail latency (p99)< 50ms> 100ms
Location update ingestion rate42K QPS< 30K QPS
WebSocket connections5M 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

SLOTargetError Budget (30 days)
Search availability99.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 uptime99.95%21.6 minutes
Data durability99.999999%2.6 seconds of data loss

19. Cost Estimation

Monthly Infrastructure Cost (200M places, 50M DAU)

ComponentSpecMonthly Cost
Application servers30 × m5.xlarge (multi-region)~$8,400
PostgreSQL + PostGIS4 shards × primary + 2 replicas (r5.2xlarge)~$27,600
Redis GEO cluster12 nodes × r5.xlarge (multi-region)~$11,400
Elasticsearch cluster9 nodes × m5.2xlarge~$8,600
Kafka cluster9 nodes × m5.xlarge (multi-region)~$5,100
WebSocket fleet20 × c5.xlarge~$4,200
Load balancers (multi-region)6 ALBs~$600
S3 (photos, backups)~50TB~$1,200
CloudFront / CDN20TB/month egress~$1,700
Monitoring (Datadog)30 hosts, custom metrics~$3,000
Route 53DNS + health checks~$100
Total~$71,900/month

Cost Optimization Strategies

StrategySavingsTrade-off
Reserved instances (1yr)30-40%Upfront commitment
Graviton instances20%ARM compatibility testing
Archive old reviews to S330% on DB storageSlower historical queries
Compress Redis values40% on Redis memoryCPU overhead
Regional caching50% on DB readsEventual consistency

Cost per Query Analysis

Query TypeInfrastructure CostRevenue per QueryMargin
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

YearPlacesDAUQPS (search)StorageMonthly Cost
Year 1200M50M17K2.4 TB$72K
Year 2300M100M35K5 TB$120K
Year 3400M150M50K8 TB$180K
Year 5500M250M80K15 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 ScenarioImpactRecovery TimeMitigation
Redis cluster failureLocation tracking stops, search falls back to DB30 seconds (failover)Multi-AZ Redis, read replicas
Primary database failureNo new place creation, reads from replica1-2 minutesStreaming replication, auto-failover
Region-wide outageAll services in region down5-15 minutes (DNS failover)Multi-region with GeoDNS
Kafka cluster failureLocation updates buffered, analytics delayed2-5 minutesMulti-AZ Kafka, mirrored topics
Elasticsearch outageText search unavailable, nearby search still works5-10 minutesGraceful 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

ComponentDetails
Spatial indexH3 (Uber's hexagonal hierarchical spatial index)
Location updates19M trips/day, position updates every 4 seconds
StorageSchemaless (custom distributed store) + Redis
MatchingGeohash-based partitioning with demand/supply balancing
ETA predictionML model on road graph + real-time traffic
Scale19M trips/day across 70+ countries

Google Maps Platform

ComponentDetails
Places database200M+ places, 25M updates/day
Spatial indexS2 Geometry + custom hierarchical tiles
Real-time dataLive traffic from Android phones + GPS probes
SearchCombined text + location ranking with ML
CacheGlobal CDN with tile-based caching
APIPlaces API, Maps JavaScript API, Directions API

Yelp's Proximity Search

ComponentDetails
Places database200M+ businesses, 38M monthly visitors
Spatial indexCustom geohash-based sharding
SearchElasticsearch with geo_point + text relevance
CacheMemcached + Redis with L1 client cache
Reviews200M+ reviews, real-time feed
RecommendationsML-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 CaseImpactSolution
International Date Line crossingPoints near ±180° longitude may wrap aroundNormalize coordinates, handle wrap-around in distance calc
Pole locations (±90° latitude)Geohash distortion at polesUse S2 or H3 which handle spherical geometry
GPS drift (urban canyons)Inaccurate positions, false geo-fence triggersApply Kalman filter, increase geo-fence buffer zone
Zero-radius searchNo results for exact coordinate matchDefault to 1km minimum radius
Duplicate placesSame restaurant listed twiceDeduplication by name + location proximity
High-density areas (Times Square)1000+ places in 100m radiusCap results, implement pagination, use ranking
No results foundEmpty response for remote areasExpand radius progressively, suggest alternatives
Location spoofingFraudulent location updatesServer-side validation, GPS + cell tower cross-check
Mass migration (sports events)Sudden demand spike in one areaAuto-scaling + cache warming for event venues
Time zone boundary issuesOpening hours calculation errorsStore hours in UTC, convert to local time for display
Multi-floor buildingsSame lat/lng, different floorsAdd optional altitude field, floor-level filtering
Country border proximityNearby places in different countriesFilter by country parameter, handle multi-currency
Rapidly moving user (car/train)Location changes faster than updatesInterpolate between updates, use speed-based prediction
Geohash cell boundary casesNearest place is in adjacent cellAlways 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

Q1: How do you find all places within a given radius efficiently?

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.

Q2: How would you handle a real-time location tracking system for 5 million drivers?

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.

Q3: What's the difference between geohash, S2, and H3?

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.

Q4: How do you handle geo-fence checks at scale (millions of devices)?

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).

Q5: How do you scale the proximity search to handle 50K QPS?

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.

Q6: How would you design the ranking algorithm for nearby search results?

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.

Q7: How do you handle the International Date Line and pole edge cases?

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.

Q8: How do you prevent location spoofing in ride-hailing?

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

StepProximity Service Approach
Requirements200M places, 50M DAU, 100ms search latency
Back-of-envelope17K QPS searches, 42K QPS location updates, 2.4TB storage
Data modelPlaces with lat/lng, reviews, user locations
API designGET nearby, GET details, POST location update, WebSocket tracking
ArchitectureRedis GEO + PostGIS + Elasticsearch + Kafka
Deep diveQuadtree/geohash indexing, distance algorithms, ranking
ReliabilityMulti-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

MetricValue
Haversine accuracy±0.5% of true distance
Geohash precision 7~153m × 153m cell
Redis GEO query time< 1ms for 1M members
PostGIS GiST index10-50ms for 200M rows
Location update frequencyEvery 3 seconds (moving)
WebSocket fan-out1M 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.

flowchart LR subgraph Ingestion["Data Ingestion"] MOB[Mobile Apps] WEB[Web Dashboard] API[Partner APIs] BATCH[CSV Import] end subgraph Processing["Stream Processing"] KAFKA[Kafka] VALIDATE[Validator] ENRICH[Enricher] DEDUP[Deduplicator] end subgraph Storage["Multi-Store Storage"] PG[(PostgreSQL
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

SignalMatch ThresholdWeight
Name similarity (Levenshtein)< 3 edits40%
Address similarityExact match30%
Phone number matchExact match15%
Location proximity< 100 meters10%
Category matchSame category5%

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

PlatformPlacesDaily SearchesLocation Updates/secArchitecture
Google Maps200M+5B100K+S2 tiles + Bigtable + Spanner
UberN/A (drivers)19M trips50KH3 + Schemaless + Redis
Yelp200M+38M/monthN/AGeohash + Elasticsearch + Memcached
Foursquare105M+10M/dayN/AS2 + custom spatial DB
DoorDash500K+1M+/day10K+Geohash + PostGIS + Redis
Pokémon GON/A (S2 cells)100M+ active1M+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

MetricTargetDescription
Evaluation Latency< 5msTime to evaluate all geofences for one location update
Active Geofences100K+Number of simultaneously active geofence boundaries
Location Updates/sec100K+Location updates processed per second
Entry/Exit Accuracy99.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

RequirementImplementationRetention
Consent ManagementOpt-in per purpose, granular controlsUntil revoked
Data MinimizationCity-level by default, precise only when neededActive session only
Right to ErasureAsync deletion pipeline, 30-day SLAImmediate purge
Data PortabilityExport as GeoJSON on user request90 days on export
Anonymizationk-anonymity with k=50 for analyticsIndefinite (anonymized)

Ayodhyya — System Design Blog Series

Proximity / Location-Based Service — Senior+ Guide