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
- Lag during zoom/pan: Markers follow map movement with a 1–2 frame delay
- Position offset on zoom out: Coastal city markers appear over the ocean
- 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 Markers | GeoJSON Layers | |
|---|---|---|
| Rendering | CSS transform | WebGL canvas |
| Zoom sync | Laggy | Perfectly synced |
| Performance | Degrades with marker count | Smooth with thousands of points |
| Styling | Full CSS flexibility | Layer paint/layout properties |
| Click events | DOM addEventListener | map.on(‘click’, layerId) |
| Selection highlight | Direct style modification | Filter + separate layer |
| Complex HTML | Possible | Not 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.