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 pixelmath 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
readPixelsper 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
38pxicons: a full redraw took roughly 200 ms - at
8pxicons: 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)
})
Using it
npm install leaflet leaflet-webgl-markers
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)
})
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
- Live demo: https://tang-tc.github.io/leaflet-webgl-markers/
- Repository: https://github.com/tang-tc/leaflet-webgl-markers
- npm: https://www.npmjs.com/package/leaflet-webgl-markers
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)