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.

2 min read

Sabin Shrestha

Full-Stack Developer — Next.js, React & React Native

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.

queries/getMapPins.js
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 #

routes/mapPins.js
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 })
  })
})
Note
  • ST_Distance_Sphere here 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', 60 above). Caching a viewport forever means new pins silently don't show up until something else evicts the key.
© 2026 Sabin Shrestha