Mapbox GeoJSON clustering: enable it or render every point?
Decide when to enable native GeoJSON clustering in Mapbox GL JS, how to style clusters, and how to validate click and performance behavior.
03 Nov 2025, 18:13 UTC

The decision and constraints
You are loading a GeoJSON file with thousands of point features into a Mapbox GL JS map. At low zoom levels the markers overlap, the layer redraws slowly, and panning feels choppy. You need to decide whether to turn on the SDK’s native GeoJSON clustering or keep every point as an individual feature.
Useful takeaway: enable clustering when you have more than a few thousand points and users can tolerate points merging into circles at lower zoom levels. Keep clustering off when your application must select or open a popup for any individual point at any zoom, or when the dataset is small and sparse.
This guide assumes Mapbox Maps SDK for JavaScript (Mapbox GL JS) version 2.x and a GeoJSON source containing point features. Clustering is powered by Supercluster and runs on the browser’s main thread.
Supported options compared
| Option | Type | Default | Effect |
|---|---|---|---|
cluster | boolean | false | Groups nearby points into cluster circles below clusterMaxZoom. |
clusterMaxZoom | number | 14 | Highest zoom level where clustering still occurs. |
clusterRadius | number | 50 pixels | Radius within which points are grouped into one cluster. |
clusterProperties | object | none | Custom aggregates such as sums or averages over clustered points. |
Trade-offs
Clustering enabled
- Fewer rendered symbols at low zoom, which usually means smoother panning and less visual overlap.
- A clicked cluster is not a single point feature; your code must decompose it or wait for the user to zoom in.
- Cluster IDs are session-specific and should not be stored as permanent identifiers.
Clustering disabled
- Every feature is individually selectable at every zoom level, with no decomposition logic required.
- Rendering and frame rates can degrade as the point count grows; the exact threshold depends on the device and viewport size.
Implementation: enable clustering on a GeoJSON source
Add the source in your map initialization code, after the map object exists. No special permissions are required beyond deploying your application. Replace ./data/earthquakes.geojson with the path to your GeoJSON file.
map.addSource('earthquakes', {
type: 'geojson',
data: './data/earthquakes.geojson',
cluster: true,
clusterMaxZoom: 14,
clusterRadius: 50,
clusterProperties: {
mag_sum: ['sum', ['get', 'mag']]
}
});
Risk: changing source options changes runtime rendering. Reload the page to see the effect.
Style clusters and unclustered points in separate layers so the point_count property is available only on cluster features.
map.addLayer({
id: 'clusters',
type: 'circle',
source: 'earthquakes',
filter: ['has', 'point_count'],
paint: {
'circle-color': [
'step',
['get', 'point_count_abbreviated'],
'#514d51',
100,
'#f1f075'
],
'circle-radius': [
'step',
['get', 'point_count'],
20,
100,
30,
500,
40
]
}
});
map.addLayer({
id: 'unclustered-points',
type: 'circle',
source: 'earthquakes',
filter: ['!', ['has', 'point_count']],
paint: {
'circle-color': '#008000',
'circle-radius': 6
}
});
point_count holds the number of original features in the cluster. point_count_abbreviated is a rounded display string such as "100+".
Handling clicks on clusters
Because a cluster represents many points, a click handler must either decompose the cluster or zoom into it.
map.on('click', 'clusters', (event) => {
const features = map.queryRenderedFeatures(event.point, {
layers: ['clusters']
});
if (features.length === 0) return;
const clusterId = features[0].properties.cluster_id;
map.getSource('earthquakes').getClusterChildren(clusterId).then((children) => {
const bounds = new mapboxgl.LngLatBounds();
children.forEach((child) => {
bounds.extend(child.geometry.coordinates);
});
map.fitBounds(bounds, { padding: 20 });
});
});
clusterId is valid only for the current map session. Do not persist it in URLs, databases, or user preferences.
Validation: what to check in DevTools
- Open Chrome or Firefox DevTools (F12), then open the Console tab.
- Load the map at zoom 5. Run
map.queryRenderedFeatures({ layers: ['clusters'] })and confirm the returned features includepoint_countandcluster_id. - Zoom to 16, above the default
clusterMaxZoom. Run the same query on the unclustered layer and confirm features no longer carrypoint_count. - Open the Performance panel, record a pan-and-zoom sequence with clustering enabled, then reload with
cluster: falseand record again. Compare frame times yourself; results depend on device and viewport, so treat either recording as a local observation, not a universal benchmark. - Inspect a cluster feature’s properties to confirm custom aggregates such as
mag_sumare present whenclusterPropertiesis configured.
Limitations and rollback
- Initial GeoJSON parse cost applies whether clustering is on or off; files over roughly 1 MB can still slow initial load.
- Sparse datasets may not benefit from clustering and can add unnecessary decomposition overhead.
- Setting
clusterMaxZoomtoo high keeps clusters visible at detailed zooms where users expect individual markers. - Clustering runs on the main thread, so very large sources can block interaction during load.
Rollback: set cluster: false, remove the clusters layer and click handler, then redeploy. Because this changes runtime map behavior, test the change on a staging page before production.
Practical check: after enabling clustering, click a visible cluster at low zoom and confirm the map zooms or fits to the decomposed children. If the click does nothing, verify the layer ID in the click handler matches the layer using the ['has', 'point_count'] filter.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.