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.
An interactive map needs "every pin inside this rectangle," refreshed as the user pans and zooms — a very different access pattern from a single nearby-search request. Two things make it cheap at scale: caching by the exact viewport rather than by user, and clustering overlapping pins in SQL so the client never receives (or renders) a pile of markers stacked on top of each other.
function getMapPins(pool, redisClient, { swLat, swLng, neLat, neLng }, callback) {
const cacheKey = `map_pins:${swLat}:${swLng}-${neLat}:${neLng}`
redisClient.get(cacheKey, (err, cached) => {
if (cached) {
return callback(null, JSON.parse(cached))
}
const sql = `
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_id
`
pool.query(sql, [swLat, neLat, swLng, neLng], (err, rows) => {
if (err) return callback(err)
const featureCollection = {
type: 'FeatureCollection',
features: rows.map((row) => ({
type: 'Feature',
properties: {
id: row.vendor_id,
title: row.stacked_places > 1 ? '' : row.store_name,
icon: row.stacked_places > 1 ? 'clusterIcon' : row.store_type,
},
geometry: { type: 'Point', coordinates: [row.lng, row.lat] },
id: `place-${row.vendor_id}`,
})),
}
redisClient.set(cacheKey, JSON.stringify(featureCollection), 'EX', 60)
callback(null, featureCollection)
})
})
}
module.exports = { getMapPins }Usage #
router.get('/map-pins', (req, res) => {
const { swLat, swLng, neLat, neLng } = req.query
getMapPins(pool, redisClient, {
swLat: parseFloat(swLat), swLng: parseFloat(swLng),
neLat: parseFloat(neLat), neLng: parseFloat(neLng),
}, (err, geojson) => {
if (err) return res.status(400).send({ message: err.message })
res.status(200).send({ content: geojson })
})
})ST_Distance_Spherehere is doing double duty as a proximity check — anything within 10 meters of another pin counts as "stacked," so the client renders one cluster marker instead of several overlapping ones. Tune that distance to whatever pixel-overlap makes sense at your typical zoom level.- The cache key is built from the viewport bounds, not the user — two different users looking at roughly the same area both hit the cache instead of MySQL.
- Set a real TTL on the cache entry (
'EX', 60above). Caching a viewport forever means new pins silently don't show up until something else evicts the key.
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.
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.
Archery Garage Mobile App
Cross-platform event management application with location tracking and cloud backend.