Marker Clustering — Handling Thousands of Markers

Why Marker Clustering Matters

A camping map with hundreds or thousands of campsite markers becomes unusable — overlapping markers, slow rendering, and information overload. Marker clustering solves this by grouping nearby markers into clusters at lower zoom levels, and splitting them as the user zooms in.

Why this matters for your career:

  • Marker clustering is essential for any map with more than ~50 markers
  • Without clustering, map performance degrades rapidly
  • Cluster algorithms are a classic computational geometry problem
  • The technique transfers to any mapping application (real estate, logistics, tourism)

What Is Marker Clustering?

Marker clustering is a technique that groups nearby markers into a single cluster icon. The cluster shows the count of markers it contains. As the user zooms in, clusters split into smaller groups until individual markers are revealed.

How Clustering Works

Zoom Level 6:         [42]
                        |
Zoom Level 8:    [12]        [30]
                    |          |
Zoom Level 10:  [4] [8]   [15] [15]
                    |          |
Zoom Level 12: Individual markers visible

Implementing MarkerCluster

Installation

npm install leaflet.markercluster
# or
<script src="https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js"></script>

Basic Usage

import L from 'leaflet';
import 'leaflet.markercluster';

// Create a marker cluster group
const mcg = L.markerClusterGroup({
  maxClusterRadius: 50,       // Cluster markers within 50px
  spiderfyOnMaxZoom: true,    // Show individual markers at max zoom
  showCoverageOnHover: false, // Don't show cluster coverage area
  zoomToBoundsOnClick: true   // Zoom to cluster bounds on click
});

// Add markers to the cluster group
campsites.forEach(site => {
  const marker = L.marker([site.latitude, site.longitude])
    .bindPopup(`<b>${site.name}</b><br>${site.description}`);

  mcg.addLayer(marker);
});

// Add the cluster group to the map
map.addLayer(mcg);

Configuration Options

| Option | Default | Description | |--------|---------|-------------| | maxClusterRadius | 80 | Maximum distance (px) to group markers | | spiderfyOnMaxZoom | true | Show all markers when zoomed in | | showCoverageOnHover | true | Highlight cluster coverage area | | zoomToBoundsOnClick | true | Zoom to cluster bounds on click | | disableClusteringAtZoom | null | Disable clustering at this zoom level | | chunkedLoading | false | Add markers in chunks to avoid freezing | | chunkInterval | 200 | Milliseconds between chunks | | chunkDelay | 50 | Delay before starting chunked loading | | singleMarkerMode | false | Treat single markers as clusters | | removeOutsideVisibleBounds | true | Don't render markers outside viewport |

Custom Cluster Icons

const mcg = L.markerClusterGroup({
  iconCreateFunction: function(cluster) {
    const count = cluster.getChildCount();
    let size = 'small';
    let color = '#22c55e';

    if (count >= 100) {
      size = 'large';
      color = '#ef4444';
    } else if (count >= 20) {
      size = 'medium';
      color = '#f59e0b';
    }

    return L.divIcon({
      html: `<div style="
        background: ${color};
        color: white;
        width: ${size === 'large' ? '50px' : size === 'medium' ? '40px' : '30px'};
        height: ${size === 'large' ? '50px' : size === 'medium' ? '40px' : '30px'};
        border-radius: 50%;
        display: flex;
        align-items: center;
        justify-content: center;
        font-weight: bold;
        font-size: ${size === 'large' ? '16px' : '14px'};
        border: 3px solid white;
        box-shadow: 0 2px 4px rgba(0,0,0,0.3);
      ">${count}</div>`,
      className: '',
      iconSize: size === 'large' ? [50, 50] : size === 'medium' ? [40, 40] : [30, 30]
    });
  }
});

Chunked Loading for Large Datasets

When adding thousands of markers, use chunked loading to keep the UI responsive:

const mcg = L.markerClusterGroup({
  chunkedLoading: true,
  chunkInterval: 100,  // 100ms per chunk
  chunkDelay: 500       // 500ms delay before starting
});

// Add 5000 markers without freezing the browser
const markers = [];
for (let i = 0; i < 5000; i++) {
  const marker = L.marker([
    23.5 + Math.random() * 3,
    120.0 + Math.random() * 3
  ]);
  markers.push(marker);
}

mcg.addLayers(markers);  // chunkedLoading keeps UI responsive
map.addLayer(mcg);

Advanced Cluster Algorithms

For very large datasets (50,000+ markers), consider server-side clustering:

async function loadClusteredMarkers(bounds, zoom) {
  const url = `/api/campsites/clustered?` +
    `sw_lat=${bounds.getSouth()}&sw_lng=${bounds.getWest()}` +
    `&ne_lat=${bounds.getNorth()}&ne_lng=${bounds.getEast()}` +
    `&zoom=${zoom}`;

  const response = await fetch(url);
  const clusters = await response.json();

  clusters.forEach(cluster => {
    if (cluster.count === 1) {
      // Single marker
      const marker = L.marker([cluster.lat, cluster.lng]);
      mcg.addLayer(marker);
    } else {
      // Cluster group
      const clusterIcon = L.divIcon({
        html: `<div class="cluster-icon">${cluster.count}</div>`,
        iconSize: [40, 40],
        className: ''
      });
      const clusterMarker = L.marker([cluster.lat, cluster.lng], {
        icon: clusterIcon
      });
      mcg.addLayer(clusterMarker);
    }
  });
}

Performance Comparison

| Dataset Size | Without Clustering | With Clustering | |-------------|-------------------|-----------------| | 50 markers | Fast | Fast | | 500 markers | Noticeable lag | Fast | | 5000 markers | Browser freezes | Smooth | | 50,000 markers | Crash | Smooth (with chunked loading) | | 500,000 markers | Crash | Smooth (with server-side clustering) |

Best Practices

| Practice | Reason | |----------|--------| | Use maxClusterRadius: 50-80 | Good balance between grouping and detail | | Enable chunkedLoading for large datasets | Keeps UI responsive during marker addition | | Customize cluster icon by count | Visual cues for density (green/yellow/red) | | Use spiderfyOnMaxZoom: true | See all individual markers at max zoom | | Use removeOutsideVisibleBounds: true | Don't render markers outside the viewport | | Consider server-side clustering for 50k+ | Send only clustered data from the server | | Use popups instead of tooltips for details | Popups work better on mobile touch | | Test on mobile devices | Clustering that works on desktop may lag on mobile |

Summary

Marker clustering is essential for any map application with more than a few dozen markers. Leaflet.markercluster provides a fast, configurable clustering solution. For very large datasets, use chunked loading or server-side clustering. Custom cluster icons provide visual density feedback.

Key takeaways:

  • L.markerClusterGroup() creates a cluster group
  • maxClusterRadius controls how close markers must be to cluster
  • chunkedLoading prevents browser freezing with large datasets
  • Custom cluster icons change color/size based on count
  • spiderfyOnMaxZoom reveals individual markers at highest zoom
  • Without clustering: 5000+ markers causes freezing
  • With clustering: 50,000+ markers is smooth
  • For 100k+ markers, use server-side clustering

What's Next: Geolocation

The next chapter covers geolocation — getting user location, centering the map, calculating distances, and handling permissions.

Unlock Full Tutorial

This chapter is paid content. Join the project to unlock over 5000 words of deep analysis, including 10+ god-tier Prompts and real Source Code examples!