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.

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.

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: '© 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
- Call
map.whenReadyand wait for the tile layer’sloadevent. - Wait for marker-cluster animations, asynchronous marker data and image elements.
- Wait for
document.fonts.readywhen labels use web fonts. - Call
map.invalidateSize()after hidden tabs or resized panels, then wait a frame. - 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.

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.


