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.

For "show me things near this point" without a geospatial database: shrink the candidate set with a cheap BETWEEN bounding box, then compute the real great-circle distance with Haversine directly in SQL so sorting and pagination happen in the database, not after loading every row into Node.
function getNearby(pool, { lat, lng, radius, page = 0, limit = 10 }, callback) {
// Rough degree range for the pre-filter — ~69 miles per degree of latitude.
const range = radius / 69
const minLat = lat - range
const maxLat = lat + range
const minLng = lng - range
const maxLng = lng + range
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 table t
WHERE (t.lat BETWEEN ? AND ?) AND (t.lng BETWEEN ? AND ?)
HAVING distance <= ?
ORDER BY distance ASC
LIMIT ?, ?
`
pool.query(
sql,
[lat, lng, lat, minLat, maxLat, minLng, maxLng, radius, page * limit, limit],
callback,
)
}
module.exports = { getNearbyPlaces }Usage #
router.get('/nearby', (req, res) => {
const { lat, lng, radius, page, limit } = req.query
getNearbyPlaces(pool, { lat: parseFloat(lat), lng: parseFloat(lng), radius: parseFloat(radius), page: Number(page), limit: Number(limit) }, (err, rows) => {
if (err) return res.status(400).send({ message: err.message })
res.status(200).send({ content: rows })
})
})We can also cache the response from lat lng to optimize the api response
3959is Earth's radius in miles — swap in6371for kilometers.- The bounding box is a square and the real search area is a circle, so
HAVING distance <= ?after the box catches the corners the box over-includes. Drop it if "closest first, no hard cutoff" is all you need. - Every value above is a parameterized placeholder (
?) — never build this query with template-string interpolation of request input; that's a direct SQL injection path.
Related content
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.
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.
Serving Premium Downloads Without Exposing the File's Real Storage URL
TIL how to replace a guessable direct-download link with a permission-checked, time-limited presigned URL, so the object storage path a purchased asset actually lives at is never sent to the client.