ScreenshotNeo

BlogHow-to

How to Capture a Leaflet Map with Many Markers Using dom-to-image

Capture a dense Leaflet map reliably with dom-to-image: wait for tiles, handle CORS, preserve markers, diagnose failures, and automate exports.

By the ScreenshotNeo team30 September 20266 min read

How to Capture a Leaflet Map with Many Markers Using dom-to-image

Short answer: wait for Leaflet tiles and markers to finish rendering, then pass the map container to domtoimage.toPng() or toJpeg(). dom-to-image clones the DOM, copies styles, embeds readable images and fonts, serializes the clone through SVG foreignObject, and rasterizes it. This preserves HTML markers and controls when their assets are capture-safe, but cross-origin images and tainted canvases can make layers disappear or fail.

This guide shows a complete JavaScript implementation for a dense map, how to decide between DOM capture and leaflet-image, and how to diagnose missing tiles, icons, labels and clusters.

1. Minimal working example

Install Leaflet and dom-to-image, create a map element, add your markers, and capture that element—not the entire page wrapper.

Wait for tiles, markers and fonts before rasterizing the map container.
Wait for tiles, markers and fonts before rasterizing the map container.
npm install leaflet dom-to-image
<!-- index.html -->
<link rel='stylesheet' href='https://unpkg.com/leaflet@1.9.4/dist/leaflet.css' />
<style>#map { width: 1200px; height: 700px; }</style>
<div id='map'></div>
<button id='save'>Save PNG</button>
<script type='module'>
import L from 'leaflet';
import domtoimage from 'dom-to-image';
import 'leaflet/dist/leaflet.css';

const map = L.map('map', { preferCanvas: false }).setView([40.72, -74], 11);
const tiles = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
  maxZoom: 19,
  attribution: '&copy; OpenStreetMap contributors'
}).addTo(map);

const points = Array.from({ length: 1200 }, (_, i) => ({
  lat: 40.55 + Math.random() * 0.35,
  lng: -74.25 + Math.random() * 0.5,
  label: `Point ${i + 1}`
}));
points.forEach(p => L.marker([p.lat, p.lng], { title: p.label }).bindTooltip(p.label).addTo(map));

function onceLayerLoaded(layer) {
  return new Promise(resolve => {
    let pending = 0;
    layer.eachLayer(l => {
      const el = l.getElement?.();
      if (el?.tagName === 'IMG' && !el.complete) {
        pending++;
        el.addEventListener('load', done, { once: true });
        el.addEventListener('error', done, { once: true });
      }
    });
    if (!pending) resolve();
    function done() { if (--pending === 0) resolve(); }
  });
}

async function mapIsReady() {
  await new Promise(resolve => map.whenReady(resolve));
  await new Promise(resolve => {
    let finished = false;
    const finish = () => { if (!finished) { finished = true; resolve(); } };
    tiles.once('load', finish);
    requestAnimationFrame(() => requestAnimationFrame(finish));
  });
  await onceLayerLoaded(map);
  if (document.fonts?.ready) await document.fonts.ready;
  await new Promise(requestAnimationFrame);
}

document.querySelector('#save').addEventListener('click', async () => {
  await mapIsReady();
  const node = document.querySelector('#map');
  const dataUrl = await domtoimage.toPng(node, {
    bgcolor: '#fff', cacheBust: true,
    width: node.scrollWidth, height: node.scrollHeight,
    style: { transform: 'none' }
  });
  const a = document.createElement('a');
  a.download = 'leaflet-map.png';
  a.href = dataUrl;
  a.click();
});
</script>

The animation-frame fallback is only for already-cached tiles. Use your application’s real loading state whenever possible. A fixed timeout alone can capture a half-painted map.

2. Make a dense map exportable

Markers and clustering

Thousands of individual DOM markers increase layout and cloning work. Leaflet’s FAQ recommends Leaflet.markercluster for thousands of markers and suggests trying Canvas for large vector datasets; it gives no universal threshold or benchmark. Clustering changes what is shown at a zoom level, so capture only after clusters have settled and confirm that the clustered representation is intended.

import 'leaflet.markercluster';
const group = L.markerClusterGroup({ chunkedLoading: true });
points.forEach(p => group.addLayer(L.marker([p.lat, p.lng])));
map.addLayer(group);
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
// Capture map.getContainer() after the cluster group's animation ends.

Vector layers and Canvas

Canvas can reduce DOM overhead for large paths, but a canvas containing a cross-origin image is tainted. A tainted canvas cannot be read back by dom-to-image. Use CORS-enabled tile and image servers and configure crossOrigin where the layer library supports it.

HTML icons, labels and controls

L.divIcon, tooltips, legends and cluster labels are HTML. DOM capture can include them if they are inside the selected node and their fonts and images are available. Keep attribution visible; the Leaflet Quick Start notes that providers commonly require it and tile terms still apply to exported images.

3. Capture options that matter

Option Use Watch for
toPng(node, options) Lossless output with transparency support Large data URLs consume memory
toJpeg(node, options) Smaller photographic output Set bgcolor; JPEG has no alpha
width/height Explicit capture dimensions Use rendered map dimensions
style Override cloned-node CSS Reset transforms only when needed
filter Exclude controls or overlays Filtering a parent removes its children
cacheBust Add cache-busting query strings Does not bypass CORS policy
const png = await domtoimage.toPng(map.getContainer(), {
  bgcolor: '#ffffff', width: map.getSize().x, height: map.getSize().y,
  filter: node => !node.classList?.contains('export-exclude')
});
const jpeg = await domtoimage.toJpeg(map.getContainer(), { quality: 0.92, bgcolor: '#fff' });

4. Readiness checklist

  1. Call map.whenReady and wait for the tile layer’s load event.
  2. Wait for marker-cluster animations, asynchronous marker data and image elements.
  3. Wait for document.fonts.ready when labels use web fonts.
  4. Call map.invalidateSize() after hidden tabs or resized panels, then wait a frame.
  5. Capture the map element and inspect the output at 100% for absent tiles, icons, labels and overlays.

5. Why layers disappear

Symptom Likely cause Fix
Blank or rejected promise Cross-origin image or tainted canvas Use CORS-enabled resources, same-origin proxying, or remove the offending layer.
Missing basemap tiles Non-readable tile responses or early capture Inspect responses, wait for tile load, and confirm provider terms.
Icons missing Bad asset URL or embedding failure Use a resolvable URL, preload it, and inspect the console.
Cluster text absent Capture during animation or outside the owning node Wait for animation and capture the map container.
Wrong font Web font was not loaded or embedded Await document.fonts.ready and verify the response.
Output clipped Transform, overflow or incorrect dimensions Set explicit dimensions and reset transform in style.
Browser freezes Very large DOM, dimensions or data URL Cluster markers, reduce size, use JPEG, or split the export.

6. dom-to-image versus leaflet-image

leaflet-image renders supported Leaflet layers rather than cloning arbitrary DOM. Its documentation requires CORS support from tile providers and marker images; in Leaflet 1.x, included vector layers must use Canvas. It does not rasterize HTML features such as L.divIcon, controls or HTML marker-cluster output. Choose it for CORS-readable tiles, images and Canvas vectors. Choose dom-to-image when the visible DOM, including HTML labels and controls, must be preserved.

7. Performance, reliability and cost

  • Reduce live rendering work first: cluster markers and consider Canvas for large vector data.
  • Capture at the smallest dimensions that meet requirements; pixel count drives memory and encoding time.
  • Cache stable exports, invalidating when map state, zoom, data or style changes.
  • Record browser version, tile provider, zoom, layer types and failure symptoms for reproducibility.
  • Respect basemap attribution and provider usage terms in every saved image.

8. Or skip the browser setup

ScreenshotNeo accepts one GET request and returns a PNG, JPEG, WebP or PDF. It can wait for a selector or network idle, run custom JavaScript, set headers and cookies, choose a viewport and device preset, block requests, hide selectors, capture one element, and use a cache TTL. Cookie banners, newsletter popups and chat widgets are removed before the shot; bot checks, blank pages and failed loads are never billed. Its MCP server lets Claude, Cursor and other MCP clients take screenshots.

Capture only the map content you need, with unwanted overlays removed first.
Capture only the map content you need, with unwanted overlays removed first.

For a public map URL, see the ScreenshotNeo API docs:

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://example.com/map -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://example.com/map"}, timeout=90)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://example.com/map' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
require('node:fs').writeFileSync('shot.webp', Buffer.from(await res.arrayBuffer()));

One thousand screenshots a month are free with no card; paid plans start at $5 for 3,000. Create a free ScreenshotNeo account.

9. FAQ

Can dom-to-image capture a map outside the viewport?

It captures the selected DOM node at the dimensions you provide. The map must already be laid out at that size; it does not create a geographic export or fetch unloaded tiles.

Should I use a fixed delay?

Use tile, data and cluster readiness events. A delay can be a small fallback for cached paints, but no universal delay works across networks and browsers.

Does clustering guarantee faster export?

No. Leaflet recommends clustering for thousands of markers, but supplies no universal threshold or benchmark.

Can I remove attribution?

Only if the provider’s terms allow it. Keep required attribution in the captured node.

What should I try when one custom layer fails?

Capture the base map, then add layers back one at a time. The first failure usually identifies a non-CORS image, tainted canvas or unsupported HTML resource.

Sources: dom-to-image README, Leaflet Reference, leaflet-image README, Leaflet FAQ, and Leaflet Quick Start.