DEV Community

tang-tc
tang-tc

Posted on

Rendering 1 Million Markers in Leaflet with WebGL

Leaflet's default markers are DOM elements. That is a great design for a few dozen markers — but it falls apart when your dataset grows. Every marker costs DOM nodes, layout, event listeners, and paint. Around a few thousand markers, most browsers start to feel it. At a hundred thousand, the map becomes unusable.

Our use case is visualizing very large point datasets. To pin down the upper bound of the approach, I ran a stress test with a million points on one Leaflet map. This post explains how leaflet-webgl-markers does it: one WebGL canvas, projection done on the GPU, and picking done with an invisible color buffer.

Why DOM and Canvas hit a wall

Leaflet gives you two native paths:

Approach Realistic scale What breaks
L.Marker (DOM) hundreds to low thousands one DOM node per marker: layout, events, memory
L.Canvas renderer tens of thousands of vector shapes transforms the container while moving, then redraws every path on the CPU at moveend

Neither is designed for "big data on a map". The problem is not Leaflet — DOM nodes and CPU-side redraws are simply the wrong tool once you reach six digits of points.

The idea: one canvas, projection on the GPU

leaflet-webgl-markers renders every marker from a single WebGL canvas:

  • Lat/lng lives in a vertex buffer. One marker is a handful of floats, not a DOM node. A million markers is a few tens of megabytes of buffer, not a million elements.
  • Mercator projection runs in the vertex shader. The CPU never projects coordinates per frame. The (lat, lng) -> world pixel math is a few lines of GLSL, executed in parallel on the GPU.
  • Dragging redraws nothing. While you pan, the canvas follows the map with a CSS transform. The vertex buffer is not touched, and no JS runs per frame. One redraw happens on moveend, when the view has settled.

That last point is the key to the "million markers without freezing" claim: the expensive pass happens once per gesture, not once per frame.

Picking without DOM: color-coded FBO

Markers on a canvas have no DOM node, so how do clicks work?

When you subscribe to an interaction event (click, mouseover, …), the layer renders a second, invisible pass into a framebuffer. Each marker is drawn with a unique encoded color instead of its real color. On a pointer event, the layer reads back that one pixel and decodes the color back into the marker.

A few details make this solid rather than just clever:

  • Picking is O(1): one readPixels per pointer event, regardless of how many markers exist.
  • Transparent edges work: a 3×3 Gaussian-weighted neighborhood handles anti-aliased icon borders, so you hit what you visually click.
  • Pixels and the decode table are published atomically from the same frame. A pointer event never reads "new pixels + old table" or the reverse.
  • It refuses to guess while moving. During a drag or zoom animation the published frame no longer matches the viewport, so picking returns "unavailable" instead of resolving the wrong marker. It recovers on moveend.
  • It is subscription-enabled: with no interaction listeners, the pick framebuffer is never created and never read.

A redraw is cheap until it isn't

Both passes have a small fixed cost; the real expense is GPU rasterization, which scales with visible markers × icon area. In the 1,000,000-point stress test with every marker visible on one canvas:

  • at 38px icons: a full redraw took roughly 200 ms
  • at 8px icons: roughly 50 ms

That is the honest trade-off: you can drag a million stress-test points smoothly, but the final moveend redraw has a cost proportional to how many pixels you cover. The package deliberately ships no built-in LOD policy — it gives you setIconSize(number), and you decide how to trade size against cost:

const layer = new WebGLMarkerLayer({ iconSize: 38 })
map.on('zoomend', () => {
  layer.setIconSize(map.getZoom() <= 8 ? 8 : 38)
})
Enter fullscreen mode Exit fullscreen mode

Using it

npm install leaflet leaflet-webgl-markers
Enter fullscreen mode Exit fullscreen mode
import L from 'leaflet'
import { WebGLMarker, WebGLMarkerLayer } from 'leaflet-webgl-markers'

const layer = new WebGLMarkerLayer({
  iconSize: 24,
  textureUrl: '/plane.png',
}).addTo(map)

// Bulk-load data — setMarkers is the fast path
layer.setMarkers(
  points.map(([lat, lng]) => new WebGLMarker({ latlng: [lat, lng] }))
)

layer.on('click', (e) => {
  // fires only when a marker is hit; e.marker is always set
  console.log(e.marker.data)
})
Enter fullscreen mode Exit fullscreen mode

It is TypeScript-first and ESM-only, and the event surface (mouseover / mouseout / click / dblclick / contextmenu) mirrors Leaflet's interaction layer. There is also an optional popup submodule.

Trade-offs to know

No free lunch. Before adopting it, know the boundaries:

  • EPSG:3857 only. The projection is hardcoded in the shader; other CRS values fail fast at addTo.
  • WebGL 1.0 required, with hardware acceleration.
  • One shared texture. All markers share the layer's icon image; it does not draw arbitrary DOM content. For a handful of custom interactive markers, keep using L.Marker.
  • No keyboard accessibility for the canvas points — the layer is pointer based, and keyboard/ARIA alternatives are left to the application.

Try it

The demo ships four scenes — ~48k real airports, live earthquakes, animated flights, and a synthetic 1M-point stress test — all rendered through the same layer.

If you're also rendering large datasets on a map, I'd love to hear what approaches you've tried — drop your questions in the comments.

Top comments (0)