“Show me restaurants within 2 km.” Simple sentence, hard problem. You can’t compute Haversine distance against every row. You need to narrow the candidate set first, then rank by distance.

This is where geohashing and spatial indexes become the query pattern, not just the storage trick.

The Expanding Ring Pattern#

Start with the user’s geohash cell. Query for locations in that cell. Not enough results? Expand to neighboring cells. Still not enough? Expand again.

public List<Location> findNearby(double lat, double lng, int minResults) {
    String userHash = Geohash.encode(lat, lng, 6); // precision 6
    List<Location> candidates = new ArrayList<>();

    // Start tight, expand outward
    for (int precision = 6; precision >= 4; precision--) {
        String prefix = userHash.substring(0, precision);
        List<String> cells = getNeighboringCells(prefix);
        candidates = locationRepo.findByGeohashPrefixIn(cells);
        if (candidates.size() >= minResults) break;
    }

    // Now compute actual distances and rank
    return candidates.stream()
        .map(loc -> new ScoredLocation(loc, haversine(lat, lng, loc)))
        .sorted(Comparator.comparingDouble(ScoredLocation::distance))
        .limit(minResults)
        .collect(Collectors.toList());
}
graph TD U["User Location"] --> P6["Search precision 6 (1.2 km cell + 8 neighbors)"] P6 --> C1{Enough results?} C1 -->|Yes| R["Rank by distance, return top N"] C1 -->|No| P5["Expand to precision 5 (5 km cells)"] P5 --> C2{Enough results?} C2 -->|Yes| R C2 -->|No| P4["Expand to precision 4 (20 km cells)"] P4 --> R style U fill:#000000,stroke:#00ff00,stroke-width:2px,color:#fff style P6 fill:#000000,stroke:#00ff00,stroke-width:2px,color:#fff style P5 fill:#000000,stroke:#00ff00,stroke-width:2px,color:#fff style P4 fill:#000000,stroke:#00ff00,stroke-width:2px,color:#fff style C1 fill:#000000,stroke:#00ff00,stroke-width:2px,color:#fff style C2 fill:#000000,stroke:#00ff00,stroke-width:2px,color:#fff style R fill:#000000,stroke:#00ff00,stroke-width:2px,color:#fff

Caching Hot Zones#

Airports, train stations, downtown areas: thousands of users searching from the same location. Computing the same expanding ring search every time is wasteful.

Cache the results by geohash prefix. User at 9q8yyk hits the cache for that cell. Cache miss? Compute, store, set a TTL of a few minutes. The multi-level caching approach works well here: an in-process cache for the current cell, Redis for neighboring cells.

Distance vs Relevance#

“Nearest” isn’t always best. The closest restaurant might have 1 star. Users expect a blend of proximity and quality. This is a relevance scoring problem in disguise. Weight distance, ratings, and other signals into a combined score. Fetch by proximity, rank by relevance.

The Boundary Trap#

Took me embarrassingly long to internalize this. A user at the edge of geohash cell 9q8yy is closer to locations in 9q8yz than to locations on the opposite side of their own cell. If you only search the user’s cell, you miss the nearest results. Always include neighbors. It’s the most common bug in geospatial search implementations.

At Oracle, we had a similar boundary problem with NSSAI configuration matching. Configs were organized hierarchically, and a request near the boundary of two config scopes had to check both scopes for the best match. Initially, the matcher only checked the exact scope. Requests at scope boundaries returned incorrect configs or no match at all. Adding neighbor-scope checks (same concept as neighboring geohash cells) fixed it and reduced config matching errors by 50%.

What I’m Learning#

Proximity search is a two-phase problem: coarse filtering (geohash/quadtree to narrow candidates) and fine ranking (actual distance computation on the small candidate set). The expanding ring pattern adapts to data density, and caching hot zones handles the reality that user locations cluster heavily in certain areas.

How do you handle the proximity vs relevance trade-off in your search features?