Building a Fast, Cached Nearby-Places Search in Node.js (No PostGIS Required)
How I built location search, map-pin clustering, and viewport caching for a mobile app on plain MySQL — bounding-box pre-filtering, Haversine distance in SQL, and Redis-cached viewport queries instead of a dedicated geospatial database.
A look at how nearby-search and the interactive map work in a Node.js/Express backend I built for a mobile events app — bounding-box filtering, Haversine distance computed in SQL, per-user enrichment in a single query, and Redis-cached, server-side-clustered map pins — all without adding a geospatial database to the stack.
The problem #
The Archery Garage mobile app needed three overlapping location features: "show me nearby vendors/stores," "show me nearby events," and a live map with pins that stay readable whether you're zoomed into a neighborhood or zoomed out to a whole city. The backend was already on MySQL/MariaDB — pulling in PostGIS or switching part of the stack to a geospatial-native database (MongoDB's $geoNear, for instance) would have meant a second database just for location queries. The goal was to get real nearby-search and map clustering out of the database already in place.
Step one: a bounding box before anything else #
Computing an exact distance for every row in a table and then filtering is expensive — it forces a full table scan. So every nearby query here does a cheap, index-friendly pass first: convert the search radius into a rough degree range and filter with a plain BETWEEN on lat/lng, which MySQL can use an index for.
// radius is in miles; ~69 miles per degree of latitude is close enough
// for a fast pre-filter — it doesn't need to be exact, just smaller than
// a full table scan.
const range = radius / 69
const minLat = lat - range
const maxLat = lat + range
const minLng = lng - range
const maxLng = lng + rangeThat shrinks the candidate set from "every row" down to "everything roughly in the neighborhood" before any real distance math happens.
Step two: Haversine, computed in SQL #
Once the candidate set is small, the actual great-circle distance is computed directly in the query with the Haversine formula, so sorting by distance and paginating both happen in the database instead of after pulling every row into Node:
const sql = `
SELECT pd.*,
(
3959 * ACOS(
COS(RADIANS(?)) * COS(RADIANS(pd.lat)) *
COS(RADIANS(pd.lng) - RADIANS(?)) +
SIN(RADIANS(?)) * SIN(RADIANS(pd.lat))
)
) AS distance
FROM inv_lts_vendor pd
WHERE (pd.lat BETWEEN ? AND ?) AND (pd.lng BETWEEN ? AND ?)
ORDER BY distance ASC
LIMIT ?, ?
`
pool.query(
sql,
[lat, lng, lat, minLat, maxLat, minLng, maxLng, page * limit, limit],
(err, rows) => {
/* ... */
},
)3959 is Earth's radius in miles (6371 for kilometers) — the rest is the standard Haversine formula, just written as SQL instead of application code. Where a hard radius cutoff actually matters (not just "closest first"), the same pattern adds a HAVING distance <= ? after the bounding box, since the box is a rough square and the real search area is a circle.
Enriching results without an N+1 #
The nearby-vendors query goes a step further and enriches every row in the same round trip: a review count, an average rating, and a per-user "is this saved?" flag, using correlated subqueries and EXISTS instead of looping over results in Node and firing a query per row:
const sql = `
SELECT pd.*,
(SELECT COUNT(*) FROM inv_review r WHERE r.product_id = pd.vendor_id) AS reviewCount,
(SELECT COALESCE(AVG(r.rating), 0) FROM inv_review r WHERE r.product_id = pd.vendor_id) AS averageRating,
EXISTS(
SELECT * FROM inv_customer_saved_places l
WHERE l.vendor_id = pd.vendor_id AND l.customer_id = ?
) AS saved
FROM inv_lts_vendor pd
WHERE (pd.lat BETWEEN ? AND ?) AND (pd.lng BETWEEN ? AND ?)
`One query, one round trip, fully enriched rows — instead of a nearby query followed by a loop of per-vendor lookups.
The map layer: caching by viewport, clustering in SQL #
The interactive map is a different access pattern than "nearby search" — it needs every pin currently inside whatever rectangle the user is looking at, and it needs to not hammer MySQL every time someone pans or zooms.
Two ideas make that work:
- Cache by viewport, not by user. The map-pins endpoint keys its Redis cache on the actual bounding box being requested (
swLat:swLng-neLat:neLng), so if two users are looking at roughly the same part of the map, the second request never touches MySQL at all. - Cluster before the response leaves the server. Rather than sending every raw pin and letting the client figure out which ones overlap, a self-join using
ST_Distance_Sphereflags pins that are close enough together to count as "stacked," so the API can return a single cluster marker instead of a pile of overlapping ones — which matters a lot on a mobile connection where every extra marker is more JSON and more rendering work.
SELECT
v.vendor_id, v.store_name, v.store_type, v.lat, v.lng,
COUNT(f.vendor_id) AS stacked_places
FROM inv_lts_vendor AS v
LEFT JOIN inv_lts_vendor AS f
ON ST_Distance_Sphere(POINT(v.lng, v.lat), POINT(f.lng, f.lat)) <= 10
WHERE (v.lat BETWEEN ? AND ?) AND (v.lng BETWEEN ? AND ?)
GROUP BY v.vendor_idAnything with stacked_places > 1 gets rendered as a cluster icon instead of an individual pin — a UX decision driven entirely by a database query, not client-side math.
Why this is worth knowing as a Node.js dev #
None of this needed a specialized geospatial database — it's a bounding-box pre-filter, one well-known trigonometric formula, a couple of correlated subqueries, and Redis used for exactly the kind of read-heavy, repeat-request pattern it's good at. That combination is honestly the more common real-world situation: most projects already have a relational database and don't want to stand up a second one just to answer "what's near me," and it's a pattern that scales fine well past a hobby project's traffic.
The code above uses parameterized placeholders (?) on purpose. The original implementation built these queries with template-string interpolation instead of parameters — functionally the same query, but vulnerable to SQL injection on any of the interpolated values. If you're pulling this pattern into a new project, parameterize every value that comes from the request, full stop.
Key takeaways #
- A bounding-box pre-filter turns "distance from every row" into "distance from a small candidate set," and it's index-friendly without any spatial extensions.
- Haversine distance can live entirely in SQL — sorting and pagination by distance don't need to happen in application code.
- Enriching rows with correlated subqueries in the same query beats an N+1 loop every time.
- Caching by the exact query shape (a viewport's bounds) rather than by user gets you cache hits between different users looking at the same area.
- Clustering nearby pins server-side keeps mobile payloads small and pushes a UX decision to the layer that already has all the data in hand.
Related content
Nearby-Places Query: Bounding Box + Haversine in MySQL
A reusable pattern for 'what's near me' search on plain MySQL — a fast bounding-box pre-filter followed by an exact Haversine distance calculation, sorted and paginated in SQL.
Redis-Cached, Server-Clustered Map Pins for a Viewport
Serve map pins for whatever bounding box a user is currently viewing, cached in Redis by the exact viewport, with overlapping pins clustered in SQL before they ever leave the server.
Archery Garage Mobile App
Cross-platform event management application with location tracking and cloud backend.