Implementing Client-Side Point Clustering in Mapbox GL JS
Learn how to implement client-side point clustering in Mapbox GL JS to handle high-density datasets using GeoJSON sources, data-driven styling, and interactive zoom-to-expand functionality.
02 Aug 2025, 16:21 UTC

The Problem: Visual Noise in High-Density Datasets
When rendering thousands of individual markers on a map, overlapping icons create "visual noise," making it impossible for users to distinguish individual points or understand the density of a specific region. This clutter also degrades browser performance as the DOM or GPU struggles to render thousands of overlapping elements.
The solution is point clustering, which groups nearby points into a single representative circle. As the user zooms in, these clusters break apart into smaller clusters or individual points. In Mapbox GL JS, this is handled client-side via the GeoJSON source, allowing for dynamic, interactive density maps without requiring a backend clustering engine.
Prerequisites
- A Mapbox GL JS environment (v2.0+ recommended).
- A GeoJSON FeatureCollection containing
Pointgeometries. - A valid Mapbox access token.
Step 1: Configure the Clustered Source
Clustering is enabled at the source level. When cluster: true is set, Mapbox creates synthetic features for clusters that contain properties like point_count and cluster_id.
map.addSource('earthquakes', {
type: 'geojson',
data: 'https://docs.mapbox.com/mapbox-gl-js/assets/earthquakes.geojson',
cluster: true,
clusterMaxZoom: 14, // Max zoom level for clustering
clusterRadius: 50 // Radius of each cluster in pixels
});
Key Parameters:
clusterRadius: Controls how close points must be to merge. A larger radius creates fewer, larger clusters.clusterMaxZoom: The zoom level at which clustering stops entirely, and all individual points are shown regardless of proximity.
Step 2: Style Clusters and Individual Points
Because a single source now produces two types of features (clusters and unclustered points), you must create separate layers with filters to style them differently.
The Cluster Circle Layer
Use a circle layer with a filter to target only features that have a point_count property. Data-driven styling can be used to change the color based on the number of points.
map.addLayer({
id: 'clusters',
type: 'circle',
source: 'earthquakes',
filter: ['has', 'point_count'],
paint: {
'circle-color': [
'step',
['get', 'point_count'],
'#51bbd6', 100, // Light blue for < 100
'#f1f075', 500, // Yellow for 100-500
'#f28cb1' // Pink for > 500
],
'circle-radius': [
'step',
['get', 'point_count'],
20, 100,
30, 500,
40
]
}
});
The Cluster Label Layer
To show the number of points inside a cluster, add a symbol layer using the point_count_abbreviated property.
map.addLayer({
id: 'cluster-count',
type: 'symbol',
source: 'earthquakes',
filter: ['has', 'point_count'],
layout: {
'text-field': '{point_count_abbreviated}',
'text-font': ['DIN Offc Pro Medium', 'Arial Unicode MS Bold'],
'text-size': 12
}
});
The Unclustered Point Layer
Finally, create a layer for the actual points that are not part of a cluster.
map.addLayer({
id: 'unclustered-point',
type: 'circle',
source: 'earthquakes',
filter: ['!', ['has', 'point_count']],
paint: {
'circle-color': '#11b2a0',
'circle-radius': 6,
'circle-stroke-width': 1,
'circle-stroke-color': '#fff'
}
});
Step 3: Implement Zoom-to-Expand Interaction
Clusters are static until the user interacts with them. To make clusters "expandable," use the getClusterExpansionZoom method to calculate the exact zoom level required to break the cluster apart.
map.on('click', 'clusters', (e) => {
const features = map.queryRenderedFeatures(e.point, { layers: ['clusters'] });
const clusterId = features[0].properties.cluster_id;
// Calculate the zoom level required to expand this specific cluster
const zoom = map.getSource('earthquakes').getClusterExpansionZoom(clusterId);
map.easeTo({
zoom: zoom,
duration: 1000
});
});
Comparison: Client-Side vs. Server-Side Clustering
| Feature | Client-Side (GeoJSON) | Server-Side (Vector Tiles) |
|---|---|---|
| Data Volume | Best for < 50k points | Scales to millions of points |
| Latency | High initial load (JSON download) | Low (tiles load on demand) |
| Flexibility | Dynamic radius/zoom changes | Static clusters baked into tiles |
Limitations and Verification
Limitations
- Memory Pressure: Loading a massive GeoJSON file into browser memory can crash mobile tabs. If your dataset exceeds 100MB, migrate to vector tiles.
- Visual Shifting: Because clustering is computed per rendered tile, a cluster may shift or split slightly as you pan the map, as the set of points within the current viewport changes.
Verification Steps
- Render Check: Load the map and verify that high-density areas show colored circles with numbers rather than overlapping dots.
- Interaction Check: Click a cluster. The map should animate (easeTo) to a closer zoom level, and the cluster should split into smaller clusters or individual points.
- Boundary Check: Zoom past the
clusterMaxZoomvalue. All clusters should disappear, leaving only individual points.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.