<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: tang-tc</title>
    <description>The latest articles on DEV Community by tang-tc (@tangchen).</description>
    <link>https://dev.to/tangchen</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4088139%2Ff5f4723f-18e1-4638-8113-5e4d3aedc211.png</url>
      <title>DEV Community: tang-tc</title>
      <link>https://dev.to/tangchen</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/tangchen"/>
    <language>en</language>
    <item>
      <title>Rendering 1 Million Markers in Leaflet with WebGL</title>
      <dc:creator>tang-tc</dc:creator>
      <pubDate>Mon, 24 Aug 2026 02:19:21 +0000</pubDate>
      <link>https://dev.to/tangchen/rendering-1-million-markers-in-leaflet-with-webgl-2l24</link>
      <guid>https://dev.to/tangchen/rendering-1-million-markers-in-leaflet-with-webgl-2l24</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;strong&gt;million&lt;/strong&gt; points on one Leaflet map. This post explains how &lt;code&gt;leaflet-webgl-markers&lt;/code&gt; does it: one WebGL canvas, projection done on the GPU, and picking done with an invisible color buffer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why DOM and Canvas hit a wall
&lt;/h2&gt;

&lt;p&gt;Leaflet gives you two native paths:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Realistic scale&lt;/th&gt;
&lt;th&gt;What breaks&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;L.Marker&lt;/code&gt; (DOM)&lt;/td&gt;
&lt;td&gt;hundreds to low thousands&lt;/td&gt;
&lt;td&gt;one DOM node per marker: layout, events, memory&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;L.Canvas&lt;/code&gt; renderer&lt;/td&gt;
&lt;td&gt;tens of thousands of vector shapes&lt;/td&gt;
&lt;td&gt;transforms the container while moving, then redraws every path on the CPU at &lt;code&gt;moveend&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The idea: one canvas, projection on the GPU
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;leaflet-webgl-markers&lt;/code&gt; renders every marker from a single WebGL canvas:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Lat/lng lives in a vertex buffer.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mercator projection runs in the vertex shader.&lt;/strong&gt; The CPU never projects coordinates per frame. The &lt;code&gt;(lat, lng) -&amp;gt; world pixel&lt;/code&gt; math is a few lines of GLSL, executed in parallel on the GPU.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dragging redraws nothing.&lt;/strong&gt; 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 &lt;code&gt;moveend&lt;/code&gt;, when the view has settled.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h2&gt;
  
  
  Picking without DOM: color-coded FBO
&lt;/h2&gt;

&lt;p&gt;Markers on a canvas have no DOM node, so how do clicks work?&lt;/p&gt;

&lt;p&gt;When you subscribe to an interaction event (&lt;code&gt;click&lt;/code&gt;, &lt;code&gt;mouseover&lt;/code&gt;, …), the layer renders a &lt;strong&gt;second, invisible pass&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;A few details make this solid rather than just clever:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Picking is O(1)&lt;/strong&gt;: one &lt;code&gt;readPixels&lt;/code&gt; per pointer event, regardless of how many markers exist.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transparent edges work&lt;/strong&gt;: a 3×3 Gaussian-weighted neighborhood handles anti-aliased icon borders, so you hit what you visually click.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pixels and the decode table are published atomically&lt;/strong&gt; from the same frame. A pointer event never reads "new pixels + old table" or the reverse.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It refuses to guess while moving.&lt;/strong&gt; 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 &lt;code&gt;moveend&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is subscription-enabled&lt;/strong&gt;: with no interaction listeners, the pick framebuffer is never created and never read.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A redraw is cheap until it isn't
&lt;/h2&gt;

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

&lt;ul&gt;
&lt;li&gt;at &lt;code&gt;38px&lt;/code&gt; icons: a full redraw took roughly &lt;strong&gt;200 ms&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;at &lt;code&gt;8px&lt;/code&gt; icons: roughly &lt;strong&gt;50 ms&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;layer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WebGLMarkerLayer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;iconSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;38&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="nx"&gt;map&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;zoomend&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;layer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setIconSize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;map&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getZoom&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;38&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Using it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;leaflet leaflet-webgl-markers
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;L&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;leaflet&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;WebGLMarker&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;WebGLMarkerLayer&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;leaflet-webgl-markers&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;layer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WebGLMarkerLayer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;iconSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;textureUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/plane.png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;addTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;map&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// Bulk-load data — setMarkers is the fast path&lt;/span&gt;
&lt;span class="nx"&gt;layer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setMarkers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;points&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(([&lt;/span&gt;&lt;span class="nx"&gt;lat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lng&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WebGLMarker&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;latlng&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;lat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lng&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}))&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nx"&gt;layer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;click&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// fires only when a marker is hit; e.marker is always set&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;marker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;h2&gt;
  
  
  Trade-offs to know
&lt;/h2&gt;

&lt;p&gt;No free lunch. Before adopting it, know the boundaries:&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Live demo: &lt;a href="https://tang-tc.github.io/leaflet-webgl-markers/" rel="noopener noreferrer"&gt;https://tang-tc.github.io/leaflet-webgl-markers/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Repository: &lt;a href="https://github.com/tang-tc/leaflet-webgl-markers" rel="noopener noreferrer"&gt;https://github.com/tang-tc/leaflet-webgl-markers&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;npm: &lt;a href="https://www.npmjs.com/package/leaflet-webgl-markers" rel="noopener noreferrer"&gt;https://www.npmjs.com/package/leaflet-webgl-markers&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

</description>
      <category>leaflet</category>
      <category>webgl</category>
      <category>javascript</category>
      <category>gis</category>
    </item>
  </channel>
</rss>
