Mapbox Point Clustering: A Source Option, Not a Layer Trick
Client-side clustering in Mapbox GL JS is a source option, not a layer trick. The three-layer pattern, click-to-expand zoom, clusterProperties aggregates, and when vector tiles are the better call.
02 Aug 2025, 07:25 UTC

Forty thousand points in a GeoJSON file will render fine as a Mapbox GL JS layer and still be useless. At country zoom you get a solid smear of dots, and the browser spends its time drawing markers nobody can tell apart. The usual fix is clustering, and the first thing worth knowing is that in Mapbox GL JS, clustering is not something you do to a layer. It is a property of the source.
That distinction drives most of the decisions that follow: where the aggregation happens, what attributes a cluster can carry, and whether the browser is the right place to do the work at all.
Clustering is configured on the source
A GeoJSON source created with cluster: true makes the library group nearby points into synthetic features before any layer sees them. Each synthetic feature carries point_count, point_count_abbreviated, and a cluster_id you can hand back to the map later. Two options control the grouping: clusterRadius (in pixels, default 50) and clusterMaxZoom, above which points stop merging and render individually.
Because clusters and single points live in the same source, the conventional rendering pattern is three layers over one source, separated by filters:
- a circle layer filtered on
['has', 'point_count']to draw the cluster bubble; - a symbol layer with the same filter to draw the count label;
- a point layer filtered on
['!', ['has', 'point_count']]for unclustered features.
Version note: this describes Mapbox GL JS v2 and v3, which are proprietary and require an access token. The older v1 open-source line differs in places, so check which major version is in your package.json or script tag before copying any example, including this one.
A worked example: three layers and click-to-expand
The code below runs in the browser after the map has loaded. It assumes a style that defines glyphs, because symbol layers with a text-field need a glyph URL to render text at all. Replace the token and data path with your own; the shape of the source and layer definitions is the part worth copying.
map.on('load', () => {
map.addSource('places', {
type: 'geojson',
data: '/data/places.geojson',
cluster: true,
clusterRadius: 50,
clusterMaxZoom: 14,
clusterProperties: {
total_population: ['+', ['get', 'population']]
}
});
map.addLayer({
id: 'clusters',
type: 'circle',
source: 'places',
filter: ['has', 'point_count'],
paint: {
'circle-color': '#4264fb',
'circle-radius': ['step', ['get', 'point_count'], 16, 100, 22, 750, 28]
}
});
map.addLayer({
id: 'cluster-count',
type: 'symbol',
source: 'places',
filter: ['has', 'point_count'],
layout: {
'text-field': ['get', 'point_count_abbreviated'],
'text-size': 12
}
});
map.addLayer({
id: 'unclustered-point',
type: 'circle',
source: 'places',
filter: ['!', ['has', 'point_count']],
paint: { 'circle-radius': 4, 'circle-color': '#11b4da' }
});
map.on('click', 'clusters', async (e) => {
const feature = e.features[0];
const clusterId = feature.properties.cluster_id;
const zoom = await map.getClusterExpansionZoom(clusterId);
map.easeTo({ center: feature.geometry.coordinates, zoom });
});
});Two checks are worth doing rather than trusting a screenshot. First, log map.querySourceFeatures('places', { filter: ['has', 'point_count'] }) at a few zoom levels and confirm cluster features appear and dissolve as you zoom. Second, click a cluster, log the value returned by getClusterExpansionZoom, and confirm the map lands on that zoom and the cluster splits.
That method returns a Promise in v2 and v3 and was callback-based in v1. If an example passes a function as a second argument and nothing happens, that mismatch is usually why.
Counting is the default; clusterProperties is how you aggregate
Out of the box a cluster knows how many points it swallowed, and nothing else. clusterProperties lets you define per-cluster aggregates from the source's own attributes, using the same expression syntax as paint properties. The aggregation belongs to the source definition, not to a layer, which means you cannot compute it lazily at render time.
In the example above, total_population: ['+', ['get', 'population']] sums a numeric attribute across every point in the cluster, so the label can show a population total instead of a raw count. Swap the operator for ['max', ['get', 'population']] and you get the largest single value instead. Whatever you need on a cluster has to be expressible this way; arbitrary per-cluster attributes are not available.
The trade-off: browser memory versus precomputed tiles
Client-side clustering is computed from the GeoJSON you supply, every time the source loads and every time you call setData. That is convenient and it is also the ceiling.
| Client-side clustering | Precomputed vector tiles | |
|---|---|---|
| Where aggregation happens | In the browser, on load and on each setData | At tile build time, in a server or pipeline |
| Scales with | Browser memory and source size | Tile size and zoom range |
| Per-cluster attributes | Only what clusterProperties defines | Whatever you bake into the tile |
| Web and native parity | Not guaranteed; native SDKs have their own APIs | Same tiles served everywhere |
Two limitations follow from the design. Clustering applies only to GeoJSON sources; a vector tile source must already be clustered when the tiles are generated. And clusterMaxZoom caps expansion, so points that remain merged beyond that zoom stay merged no matter how far the user zooms in.
For streaming or frequently changing data, throttling setData calls and reducing feature count generally matters more than fine-tuning clusterRadius. Each update re-runs the whole clustering pass.
What to do next
Load a realistically sized dataset, wrap a setData call in performance.now(), and compare clustered against unclustered rendering on your own hardware. That gives you a practical size limit instead of a guess. Then confirm current token, licensing, and billing terms on Mapbox's own pricing and terms pages, since those change independently of the library.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.