So you’re building a proximity-based payments app. Users open their phones, and bam, they see everyone nearby who can send or receive money. The concept is straightforward. The architecture is not.
Here’s the trap: the naive approach works beautifully at ten users, starts sweating at a hundred, and flatlines completely by a thousand. The “revolutionary” design mostly just reveals how little we think about spherical geometry until it’s too late.
Let’s dissect what actually happens when you need to find nearby users in real time, and why most MVPs are building a ticking scalability time bomb.
The Deceptive Simplicity of GPS Coordinates
Your phone spits out latitude and longitude. Two numbers. Easy, right?
The first instinct is to store those coordinates, stream them over Socket.IO to a central server, and run a distance calculation against every other online user. This is what I call the “kindergarten” approach, and it works precisely until the moment it doesn’t.
The math itself is fine. The Haversine formula calculates great-circle distances between two points on a sphere using the law of haversines:
a = sin²(Δlat/2) + cos(lat1)·cos(lat2)·sin²(Δlon/2)
c = 2 · atan2(√a, √(1-a))
d = R · c
This is sufficient for a 50, 100 meter radius. In fact, at those distances, you don’t even need spherical math. As one systems engineer pointed out, at the scale of 50, 100 meters, the earth is effectively flat. You can treat lat/lon as Cartesian coordinates and your error will be negligible in any populated area.
Vincenty’s formula, which accounts for the Earth being an oblate spheroid rather than a perfect sphere, is overkill. If you’re calculating distances where the oblate spheroid correction matters, those users aren’t “nearby” in any practical sense.
The real problem isn’t the formula. It’s the loop.
The O(n²) Trap
Every time a user updates their location, your naive backend calculates distances to every other online user. With 100 users, that’s ~5,000 distance calculations per update. With 1,000 users? ~500,000. With 10,000 concurrent users in a city? You’re looking at 50 million distance calculations for a single location update.
This is quadratic complexity. Your server costs grow with the square of your user base. No amount of micro-optimizing Haversine vs. Vincenty fixes this.
The bottleneck isn’t CPU, it’s the data churn. Every comparison forces you to pull every user’s coordinates into memory. Redis, Postgres, whatever you’re using, the I/O pattern becomes pathological.
Production systems don’t do this. They use a two-phase approach: a coarse spatial filter first, then exact distance calculation on the reduced set.
Geohashes: The MVP’s Best Friend
A geohash converts latitude/longitude into a short alphanumeric string by recursively dividing the world into grid cells. The longer the string, the smaller the cell:
| Geohash Length | Cell Size (approx) |
|---|---|
| 1 character | ~5,000 km |
| 2 characters | ~1,250 km |
| 3 characters | ~156 km |
| 4 characters | ~39 km |
| 5 characters | ~4.9 km |
| 6 characters | ~1.2 km |
| 7 characters | ~152 m |
| 8 characters | ~38 m |
For a 50, 100 meter radius, a 7-character geohash gives you the right granularity. Store this alongside each user’s precise coordinates. To find nearby users, query for others with the same or adjacent geohash prefixes. This reduces your candidate set from potentially thousands to, at most, the occupants of that cell and its eight neighbors.
The critical insight: geohashes are a coarse pre-filter, not a final answer. You still need to run exact distance calculations, but now on a dramatically smaller set of candidates.
Redis implements this natively through its geospatial data types. The GEOADD command stores coordinates, and GEOSEARCH (replacing the deprecated GEORADIUS) finds members within a radius or bounding box. The complexity is O(N + log(M)) where N is the number of elements in the bounding box and M is the total index size, a massive improvement over the naive approach.
Here’s how trivial this becomes with Redis:
> GEOADD users:online -73.9857 40.7484 "user:1001"
> GEOADD users:online -73.9892 40.7429 "user:1002"
> GEOADD users:online -73.9776 40.7512 "user:1003"
> GEOSEARCH users:online FROMLONLAT -73.9857 40.7484 BYRADIUS 100 m WITHDIST
1) 1) "user:1001"
2) "0.0001"
2) 1) "user:1003"
2) "62.4"
Beyond Flat Grids: Uber’s Hierarchical Spatial Index
Geohashes are great for an MVP, but they have a dirty secret: non-uniform cell sizes. At the equator, a geohash cell might be reasonably square. Near the poles, the longitude dimension compresses dramatically, making the cells rectangular and inconsistent.
This is where Uber went all-in on a different approach. They published their work on hierarchical spatial indexing using H3, which partitions the earth into hexagons rather than squares.
The advantages are subtle but real:
– Hexagons have only one distance metric (center to any neighbor), unlike squares which have both axial and diagonal distances.
– The H3 grid system has low distortion, with only 5 out of 122 partitions placed in low-use areas (like the middle of the ocean).
– Hexagonal grids better approximate circular radius searches.
The OpenSearch geohex grid aggregation documentation confirms this: “The H3 grid system works well for proximity applications because it overcomes the limitations of Geohash’s non-uniform partitions.”
If you’re building at Uber-scale, managing millions of write operations per second from drivers and riders, hexagonal indexing becomes not just nice-to-have, but architectural necessity.
The Production Stack: PostGIS as Your Ace
You don’t need to build your spatial indexing from scratch. The most pragmatic path for most teams is PostGIS: a PostgreSQL extension that adds geospatial capabilities.
The beauty of PostGIS is that it handles both phases of the problem natively:
1. Spatial indexing via R-tree indexes on geometry columns
2. Distance calculation using the ST_DWithin function, which leverages the spatial index for the coarse pass
PostGIS radius search is well-documented and battle-tested. You’re probably already using PostgreSQL, so adding PostGIS is an extension, not a new database.
-- Create spatial index
CREATE INDEX idx_users_location ON users USING GIST (location);
-- Find users within 100 meters
SELECT user_id, ST_Distance(location, ST_MakePoint(-73.9857, 40.7484)::geography) AS distance
FROM users
WHERE ST_DWithin(location, ST_MakePoint(-73.9857, 40.7484)::geography, 100);
This approach scales to the point where you can spend lots of money on alternatives before you need to. For most startups, PostGIS is the alternatives.
The Hidden Complexity: Real-Time Location Streaming
Geohashes and PostGIS solve the query problem, but there’s a deeper architectural challenge: how do you update locations in real time?
The naive Socket.IO approach creates an update storm. Every movement triggers a server event. The server must:
1. Update the geospatial index
2. Find nearby users
3. Push notifications to those users
4. Handle the fact that those users’ locations have also changed simultaneously
This is a distributed state synchronization problem masquerading as a spatial query problem.
The practical mitigations:
- Throttle updates on the client side: Don’t send location updates more frequently than every 5, 10 seconds unless the user is moving rapidly. GPS accuracy at the 50m scale doesn’t improve with 100ms sampling.
- Use WebSocket batching: Aggregate location updates on the server and process them in batches rather than individually.
- Cache proximity results: If User A was near User B 2 seconds ago, they’re almost certainly still near. Don’t recalculate on every ping.
- Implement an event-driven architecture: Use Redis Pub/Sub or Kafka to distribute location events without coupling the write path to the read path.
One developer described how FNB, a South African bank, implemented geo-payments back in 2010 using “only GPS with a small distance calculation.” The critical design choice was that both parties had to explicitly opt in, “want to send payment, want to receive payment”, meaning the system wasn’t always-on and actively removed non-interested devices from the discovery set. This is an excellent reminder that architectural simplicity often comes from product constraints, not technical wizardry.
Security and Privacy: The Elephant in the Room
Everyone talks about geohashes and spatial indexing. Nobody talks about the privacy nightmare.
Your system is tracking everyone’s precise location in real time. The security implications are severe:
- Location spoofing: What stops a malicious user from faking their GPS coordinates to appear near a target?
- Stalking risks: If discovery is always-on, users can track others’ movements.
- Overload attacks: As one commenter noted, “you could get overloaded at a crowded event” where hundreds of users appear mutually nearby.
The mitigation pattern used by production implementations involves:
– Ephemeral sessions: Location data with TTL, not persistent storage
– Opt-in discovery: Users explicitly signal when they want to be discoverable
– Proximity-only, not direction: Never reveal where a user is, only that they’re within range
– Rate limiting on queries: Prevent repeated enumeration of nearby users
These constraints aren’t just nice-to-have. They’re fundamental to the architecture. A system designed without them will need a privacy rewrite when regulators come calling.
The Architectural Decision Matrix
| Approach | Complexity | Scale Ceiling | Implementation Time |
|---|---|---|---|
| Naive Haversine | O(n²) | ~100 users | 1 day |
| Geohash pre-filter | O(k log m) | ~10K users | 1 week |
| Redis Geospatial | O(N + log M) | ~100K users | 1 day (if Redis already in stack) |
| PostGIS | O(log n) | ~1M users | 1 week (if Postgres already in stack) |
| Uber H3 hexagonal index | O(log n) | ~10M+ users | 1 month+ |
The optimal path for most teams: start with Redis geospatial for your MVP, migrate to PostGIS when you need durability and more complex queries, and only consider custom hexagonal indexing when you’re operating at Uber/Lyft scale.
The Path Forward
Building proximity-based user discovery isn’t about finding the perfect distance formula. It’s about understanding that the real challenge isn’t the math, it’s the architecture of data flow, indexing, and state management.
Your MVP can absolutely use a simple geohash and Haversine approach. The systems engineer who built FNB’s geo-payments in 2010 confirms this. But you need to design your architecture so that the spatial query layer can be swapped out without rewriting everything else.
Think about this in terms of scalability challenges and database bottlenecks, your naive approach will work until it doesn’t, and by then it’s a crisis, not a refactor.
Consider also the implications of edge computing and data proximity, processing location data at the edge could drastically reduce latency for real-time discovery.
And while we’re talking about real-time processing, the trend toward local processing and latency reduction suggests that client-side spatial filtering (even if just for coarse pre-screening) could reduce server load dramatically.
The apps that succeed aren’t the ones with the most elegant geohash implementation. They’re the ones that can onboard 10,000 users without their servers catching fire. Your architecture needs to handle the hockey-stick growth before the hockey-stick arrives.
Don’t optimize for today’s user count. Optimize for the one that kills the naive approach.




