Client-Side Clustering in Mapbox GL JS: When the Browser Should Do the Aggregation
Mapbox GL JS can cluster thousands of points entirely in the browser with cluster: true — no server required. Here's a working setup, the click-to-expand pattern, and the dataset size where you must switch to server-side aggregation.
15 Sept 2025, 20:22 UTC

You have 8,000 store locations to show on a map. The obvious move is to build a server endpoint that aggregates points into grid cells and returns counts per tile. But for datasets of this size, Mapbox GL JS can do the clustering entirely in the browser with a single source option — no backend work, no extra service to maintain. The catch is knowing where that approach stops working, because it stops working hard.
This post covers how the built-in clustering works, a working configuration with sensible defaults, and the decision line where you should hand aggregation back to a server or a tile pipeline.
What cluster: true actually does
When you add a GeoJSON source with cluster: true, GL JS groups nearby points into clusters as it generates vector tiles in the browser. Each cluster becomes a feature with two useful properties: point_count (how many points it contains) and cluster_id (an identifier you use to query or expand it). Two options control the behavior:
clusterRadius— how close points must be, in screen pixels, to merge. Default is 50. Smaller values give more, tighter clusters.clusterMaxZoom— the zoom level at which clustering stops and raw points appear. Default is 14.
You then render three layers: a circle layer for clusters (filtered on ["has", "point_count"]), a symbol layer showing the count, and a circle layer for unclustered points (filtered on ["!", ["has", "point_count"]]).
A working setup
The following goes in your map initialization code, after the map loads. It assumes Mapbox GL JS v2/v3 and a GeoJSON file of points with a numeric mag property:
map.addSource('locations', {
type: 'geojson',
data: '/data/locations.geojson',
cluster: true,
clusterRadius: 50,
clusterMaxZoom: 14,
clusterProperties: {
// Sum of "mag" across each cluster, computed in the browser
magSum: ['+', ['get', 'mag']]
}
});
map.addLayer({
id: 'clusters',
type: 'circle',
source: 'locations',
filter: ['has', 'point_count'],
paint: {
'circle-color': '#4264fb',
'circle-radius': ['step', ['get', 'point_count'], 16, 50, 22, 200, 30]
}
});
map.addLayer({
id: 'cluster-count',
type: 'symbol',
source: 'locations',
filter: ['has', 'point_count'],
layout: {
'text-field': ['get', 'point_count_abbreviated'],
'text-size': 12
}
});
map.addLayer({
id: 'unclustered-point',
type: 'circle',
source: 'locations',
filter: ['!', ['has', 'point_count']],
paint: { 'circle-color': '#4264fb', 'circle-radius': 5 }
});The clusterProperties block is the underused part: it lets you compute aggregates (sums, maxes, averages via accumulated sums and counts) at cluster time using map/reduce expression pairs, so your backend doesn't need to precompute anything.
For the click-to-expand interaction, query the rendered feature and ask the source for the zoom at which the cluster breaks apart:
map.on('click', 'clusters', (e) => {
const feature = e.features[0];
const clusterId = feature.properties.cluster_id;
map.getSource('locations').getClusterExpansionZoom(clusterId, (err, zoom) => {
if (err) return;
map.easeTo({ center: feature.geometry.coordinates, zoom });
});
});To verify it works: load the map with a few thousand points, confirm circles with counts appear at low zoom, click one and confirm the camera eases in and the cluster splits, then zoom past clusterMaxZoom and confirm individual points render.
Where client-side clustering wins — and where it loses
The win is operational: zero server code. Aggregation updates automatically when the GeoJSON changes, and clusterProperties gives you real analytics (average price per cluster, total incidents per region) without a database query. For datasets up to roughly the tens of thousands of points, this is the fastest path to a good map.
The limitation is fundamental: the browser downloads every point. Clustering reduces rendering load, not network load. A 200,000-point GeoJSON file might be 40 MB of text that must be fetched, parsed, and held in memory before the first tile renders. Past the low hundreds of thousands of points — or earlier on slow networks — you need server-side aggregation or a prebuilt vector tileset (Tippecanoe is the common tool) that ships only what's visible.
Two subtler caveats. First, clustering is computed per tile, so points near tile boundaries can occasionally group slightly differently at some zooms — invisible to users, but don't compare cluster counts across tiles as if they were exact. Second, a hybrid pattern often wins at scale: serve a pre-clustered tileset at low zooms and swap to raw GeoJSON with client clustering once the viewport is small.
Before you commit
Check three things against your actual deployment. First, your GL JS version: option names and expression syntax are stable across v2/v3, but v3 changed pricing and licensing — MapLibre GL JS implements a compatible clustering API if that's a concern. Second, your real data size: load your production GeoJSON in a browser profiler and watch parse time and memory, not just the point count. Third, your zoom range: set clusterMaxZoom to the level where individual points become meaningful, not the default, if your data is dense past zoom 14.
If your dataset fits comfortably in the browser, client-side clustering is the right call — one source option replaces an entire aggregation service. If it doesn't, decide that early, because the fix is a different architecture, not a bigger clusterRadius.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.