Client-side clustering in Mapbox GL JS: when it helps and when it blocks
Use Mapbox GL JS clustering for viewport point density, but verify token scope and feature limits before production.
20 Mar 2026, 04:16 UTC

The problem: thousands of points turn into an unreadable blob
If you load a GeoJSON source with tens of thousands of point features into Mapbox GL JS, the map can become a solid mass of overlapping circles at low zoom. Panning and zooming feels slow because every point is drawn individually, and users cannot tell where points are dense versus sparse. The usual instinct is to ask the server for pre-aggregated clusters on every map move, but that adds round-trips and complexity for interactions that only change the viewport.
The useful takeaway
Mapbox GL JS can cluster points in the browser by setting cluster: true on a GeoJSON source. The library groups nearby points into cluster features and exposes point_count so you can style cluster size and color by how many points they contain. This works well for interactive exploration of tens of thousands of points, but it is not a substitute for server-side spatial queries over millions of features or for authoritative counts used in billing or reporting.
How client-side clustering works
Clustering happens after the GeoJSON source loads. Mapbox GL JS divides the loaded features into grid cells based on clusterRadius and merges points within each cell into a cluster feature. Individual features that are not clustered carry no point_count property; cluster features do. You use separate layers with filters to draw them differently:
['has', 'point_count']selects cluster features.['!', ['has', 'point_count']]selects unclustered points.
clusterMaxZoom controls the zoom level above which clustering stops, so points always separate as the user zooms in. This is a client-side operation: the browser parses the GeoJSON and runs the grouping, so a very large file can block the main thread before the map is interactive.
Minimal working example
This example assumes Mapbox GL JS v2 and a valid access token scoped to the URL where you run it. Save the file locally, replace YOUR_MAPBOX_ACCESS_TOKEN, and open it in a browser. No special permissions are required beyond reading the file.
<!DOCTYPE html>
<html>
<head>
<meta charset='utf-8' />
<title>Mapbox clustering example</title>
<link href='https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.css' rel='stylesheet' />
<style>body { margin: 0; } #map { position: absolute; top: 0; bottom: 0; width: 100%; }</style>
</head>
<body>
<div id='map'></div>
<script src='https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.js'></script>
<script>
mapboxgl.accessToken = 'YOUR_MAPBOX_ACCESS_TOKEN';
const map = new mapboxgl.Map({
container: 'map',
style: 'mapbox://styles/mapbox/light-v11',
center: [-96, 37.8],
zoom: 3
});
map.on('load', () => {
map.addSource('points', {
type: 'geojson',
data: './points.geojson',
cluster: true,
clusterMaxZoom: 14,
clusterRadius: 50
});
map.addLayer({
id: 'unclustered-point',
type: 'circle',
source: 'points',
filter: ['!', ['has', 'point_count']],
paint: {
'circle-color': '#007cbf',
'circle-radius': 5,
'circle-stroke-width': 1,
'circle-stroke-color': '#fff'
}
});
map.addLayer({
id: 'clusters',
type: 'circle',
source: 'points',
filter: ['has', 'point_count'],
paint: {
'circle-color': [
'step',
['get', 'point_count'],
'#f1f075',
100, '#f28c1b',
750, '#e55e10'
],
'circle-radius': [
'step',
['get', 'point_count'],
20,
100, 30,
750, 40
]
}
});
});
</script>
</body>
</html>
Run this on localhost or a deployed URL that matches your token's allowed origins. If the token is restricted to specific domains, loading the page elsewhere produces authorization errors and no tiles.
Diagnostic checks
- Open browser developer tools and reload the page.
- At low zoom, inspect the
clusterslayer. Cluster features should include apoint_countproperty. - Zoom past
clusterMaxZoom. Clusters should split into individual points, and theunclustered-pointlayer should display them. - Watch the Memory and Performance tabs while loading increasingly large GeoJSON files. If parse time or main-thread blocking grows sharply, client-side clustering is becoming the bottleneck.
Do not treat the cluster count as an authoritative total. It reflects only features currently loaded in the viewport, so it is unsuitable for billing, analytics, or regulatory reporting.
Limits and when to look elsewhere
| Situation | Fit for client-side clustering |
|---|---|
| Tens of thousands of point features in one GeoJSON source | Generally suitable |
| Millions of features or complex polygon intersections | Not suitable; use server-side vector tiles |
| Counts needed for billing or compliance | Not suitable; cluster counts are viewport-only |
| Data already in vector tiles | Not applicable; clustering is a GeoJSON source feature |
Mapbox GL JS v1 and v2 differ in access-token requirements, licensing, and attribution. Confirm your SDK version and Mapbox account scope before depending on clustering in production, and re-test after upgrading because behavior and required attribution can change.
Closing
Start with a small GeoJSON file, enable cluster: true, and verify that circles split into points as you zoom. If load time or memory grows unacceptable, move aggregation to the server and serve vector tiles instead. Because this is client-side configuration only, there is no persistent state to roll back; removing the source and layers or refreshing the page returns the map to its previous behavior.