Introduction

When placing custom markers on a map with MapLibre GL JS, a common approach is to pass custom DOM elements to maplibregl.Marker. This allows flexible CSS styling, such as round markers with count badges.

However, this approach has a problem: markers lag behind the map during zoom and pan operations. The lag becomes particularly noticeable with many markers or on mobile devices, and zooming out can cause coastal markers to appear over the ocean.

This article explains the cause and a solution using GeoJSON source + layers.

Reproducing the Problem

DOM Marker Implementation (Problematic)

for (const point of dataPoints) {
  const el = document.createElement('div')
  Object.assign(el.style, {
    width: '32px',
    height: '32px',
    borderRadius: '50%',
    backgroundColor: point.color,
    border: '3px solid white',
    boxShadow: '0 2px 6px rgba(0,0,0,0.3)',
    cursor: 'pointer',
    display: 'flex',
    alignItems: 'center',
    justifyContent: 'center',
  })

  // Count badge
  if (point.count > 1) {
    const badge = document.createElement('span')
    badge.style.color = 'white'
    badge.style.fontSize = '11px'
    badge.style.fontWeight = 'bold'
    badge.textContent = String(point.count)
    el.appendChild(badge)
  }

  new maplibregl.Marker({ element: el })
    .setLngLat([point.lng, point.lat])
    .addTo(map)
}

This implementation causes the following issues.

Symptoms

  1. Lag during zoom/pan: Markers follow map movement with a 1–2 frame delay
  2. Position offset on zoom out: Coastal city markers appear over the ocean
  3. Performance degradation: DOM repositioning cost increases proportionally with marker count

Cause

maplibregl.Marker positions DOM elements using CSS transform. Each time the map is zoomed or panned, the CSS transform for every marker must be recalculated and updated. Since this happens asynchronously from the WebGL canvas rendering, a visual lag occurs.

Map rendering (WebGL)  ─── Frame N ──→ Frame N+1
Marker update (DOM)    ─── (delay) ──→ Frame N+1 or N+2

Additionally, the default anchor for maplibregl.Marker is the bottom center of the element. At low zoom levels (wide-area view), the height offset of the marker element corresponds to a large geographic distance, making markers appear shifted south of their actual coordinates. Setting anchor: 'center' mitigates this, but the lag problem remains.

Solution: GeoJSON Source + Layers

MapLibre GL JS provides a mechanism to register GeoJSON data as a source and render it as circle or symbol layers. With this approach, markers are rendered directly on the WebGL canvas, staying perfectly in sync with map movements.

Building GeoJSON Data

function buildGeoJSON(dataPoints) {
  return {
    type: 'FeatureCollection',
    features: dataPoints.map((point, i) => ({
      type: 'Feature',
      id: i,
      properties: {
        index: i,
        count: point.count,
        label: point.count > 1 ? String(point.count) : '',
        color: point.color,
      },
      geometry: {
        type: 'Point',
        coordinates: [point.lng, point.lat],
      },
    })),
  }
}

Adding Source and Layers

map.on('load', () => {
  // Add GeoJSON source
  map.addSource('points', {
    type: 'geojson',
    data: buildGeoJSON(dataPoints),
  })

  // Circle layer (marker body)
  map.addLayer({
    id: 'point-circles',
    type: 'circle',
    source: 'points',
    paint: {
      'circle-radius': [
        'case',
        ['>', ['get', 'count'], 1], 14,  // Multiple items
        10,                                // Single item
      ],
      'circle-color': ['get', 'color'],
      'circle-stroke-width': 2,
      'circle-stroke-color': 'white',
    },
  })

  // Symbol layer (count labels)
  map.addLayer({
    id: 'point-labels',
    type: 'symbol',
    source: 'points',
    filter: ['>', ['get', 'count'], 1],
    layout: {
      'text-field': ['get', 'label'],
      'text-size': 11,
      'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'],
      'text-allow-overlap': true,
    },
    paint: {
      'text-color': 'white',
      'text-halo-color': 'rgba(0,0,0,0.3)',
      'text-halo-width': 1,
    },
  })
})

Handling Click Events

With DOM markers, you could attach addEventListener directly to elements. With the layer approach, use map.on('click', layerId, handler).

map.on('click', 'point-circles', (e) => {
  if (!e.features?.length) return
  const props = e.features[0].properties
  const coords = e.features[0].geometry.coordinates

  // Show popup
  new maplibregl.Popup({ offset: 15 })
    .setHTML(`<b>${props.region}</b><br/>${props.count} items`)
    .setLngLat(coords)
    .addTo(map)
})

// Change cursor to pointer
map.on('mouseenter', 'point-circles', () => {
  map.getCanvas().style.cursor = 'pointer'
})
map.on('mouseleave', 'point-circles', () => {
  map.getCanvas().style.cursor = ''
})

Selection Highlighting

With DOM markers, you could modify el.style.border directly. With layers, use filters to apply styles only to selected markers.

// Add a separate layer for highlighting
map.addLayer({
  id: 'point-selected',
  type: 'circle',
  source: 'points',
  filter: ['==', ['get', 'index'], -1], // Hidden by default
  paint: {
    'circle-radius': 17,
    'circle-color': 'transparent',
    'circle-stroke-width': 3,
    'circle-stroke-color': '#FFD700',
  },
})

// Update filter on selection
function selectPoint(index) {
  map.setFilter('point-selected', ['==', ['get', 'index'], index])
}

Dynamic Data Updates

When data changes, update the source with setData.

const source = map.getSource('points')
source.setData(buildGeoJSON(newDataPoints))

Comparison

DOM MarkersGeoJSON Layers
RenderingCSS transformWebGL canvas
Zoom syncLaggyPerfectly synced
PerformanceDegrades with marker countSmooth with thousands of points
StylingFull CSS flexibilityLayer paint/layout properties
Click eventsDOM addEventListenermap.on(‘click’, layerId)
Selection highlightDirect style modificationFilter + separate layer
Complex HTMLPossibleNot possible (circle/symbol only)

When DOM Markers Are Appropriate

GeoJSON layers are not a universal solution. DOM markers may be more appropriate in the following cases:

  • When complex HTML structures (forms, images, rich tooltips, etc.) need to be embedded in markers
  • When the marker count is small (around 10 or fewer) and performance is not a concern
  • When CSS animations are needed

Summary

When placing many markers on a map with MapLibre GL JS, using GeoJSON source + Circle/Symbol layers instead of DOM-based maplibregl.Marker eliminates lag during zoom and pan. Count display and cluster-like representations can be achieved with a combination of circle and symbol layers.