Map Library Instance Memory in Leaflet and Mapbox

Opening the map view ten times makes the tab use a gigabyte more, Chrome warns about too many active WebGL contexts, or Leaflet throws “Map container is already initialized” when a route is revisited. This guide from Third-Party Library Memory Leaks, part of Framework-Specific Memory Optimization, explains what a map instance holds in Leaflet and in WebGL-based libraries such as Mapbox GL JS and MapLibre GL, how to tear it down completely, and when to keep one long-lived map instead.

Symptom Root Cause Immediate Action Measurable Impact
Memory jumps by 50–150 MB per map view visit Map instance never removed on unmount Call map.remove() in the component cleanup Memory returns after leaving the view
“Too many active WebGL contexts” warning New GL map per mount, old contexts not released map.remove(); or keep one map and move it Constant number of contexts
“Map container is already initialized” (Leaflet) New L.map on an element that still has a map Remove the old map first or reuse it No duplicate maps
Markers and popups retained after data refresh Marker objects replaced but not removed marker.remove() / layerGroup.clearLayers() Marker count equals data size
GPU and tile memory high on mobile Large tile cache and high-DPI rendering Bound the tile cache; lower resolution where acceptable Lower GPU and heap footprint

Root Cause: A Map Is Many Resources Behind One Element

A Leaflet map (L.map(el)) builds panes and tile <img> elements inside the container, keeps layers (tile layers, markers, vector paths) with their own DOM and event handlers, attaches listeners to the container and to window/document for panning, zooming and resizing, and stores its internal state on the container element (which is why re-initialising the same container throws). map.remove() tears all of that down: it removes layers, unbinds handlers and clears the container’s reference, allowing everything to be collected.

A WebGL map (Mapbox GL JS, MapLibre GL) is heavier. Each map creates a canvas with a WebGL context and allocates GPU textures and buffers for tiles, glyphs and sprites; it parses vector tiles in web workers; it keeps a tile cache of recently used tiles in memory; and it attaches resize and interaction handlers. map.remove() releases the WebGL context (so GPU memory is freed promptly), removes handlers, clears caches and detaches the canvas. Browsers limit the number of simultaneously active WebGL contexts; creating maps without removing old ones eventually makes the browser drop contexts, with blank maps and warnings, as described in WebGL texture and GPU memory leaks.

Two usage patterns cause most map leaks. The first is creating a map per mount without removing it — the framework removes the container element, but the map instance, its listeners, its workers’ message handlers and its GPU resources remain, and the container becomes a detached DOM node retained by the map. The second is replacing overlays instead of updating them: recreating every marker, popup or GeoJSON layer on each data refresh without removing the previous ones. Markers hold DOM elements, event handlers and often closures over application data, so a map with frequent refreshes can accumulate thousands of orphaned markers.

Because maps are expensive to create — style loading, tile fetching, shader compilation — many apps benefit from keeping one map instance for the session and moving its container between views, or hiding it rather than destroying it, as long as that is a deliberate, bounded choice.

What map.remove() releases Leaflet: panes and tile images in the DOM, layers with their DOM and handlers, container and window listeners, and state stored on the container. WebGL maps such as Mapbox GL or MapLibre: all of those plus a canvas with a WebGL context, GPU textures and buffers for tiles, glyphs and sprites, a web worker pool for tile parsing and an in-memory tile cache. In both, map.remove releases everything the library created. Leaflet map panes + tile <img> elements layers: markers, paths, popups container + window listeners state stored on the container map.remove() releases all WebGL map (Mapbox / MapLibre) canvas + WebGL context GPU textures: tiles, glyphs, sprites worker pool for tile parsing in-memory tile cache markers, popups, handlers map.remove() releases all

Step-by-Step Fix

  1. Measure a map view’s cost. Enter and leave the map view ten times; record JS heap (after GC), detached DOM and — for WebGL maps — the Task Manager’s GPU memory column. Verification: you know the per-visit growth.
  2. Remove the map on unmount. Keep the map instance in the component and call map.remove() in its cleanup. Verification: leaving the view returns heap and GPU memory to baseline; no detached container remains.
  3. Or keep one map deliberately. If creation cost is high and the map appears often, create one map at app level and move or show/hide its container instead of re-creating. Verification: exactly one map instance and one WebGL context exist for the session.
  4. Manage overlays incrementally. Keep markers in a Map keyed by feature ID; add new ones, update moved ones, and call marker.remove() for those no longer present. For many points, use a GeoJSON source and setData() (GL maps) or a canvas/marker cluster layer (Leaflet). Verification: marker object count equals the current data size after refreshes.
  5. Bound caches and resolution. Configure the GL map’s tile cache size where your library exposes it (for example a maximum tile cache option), and avoid rendering at excessive pixel ratios on mobile. Verification: GPU memory stays within your budget during long pans.
  6. Re-run the ten visits. Repeat step 1. Verification: growth per visit is within noise, and no WebGL context warnings appear.
GPU memory across ten map visits Without map.remove, GPU memory rises by about 90 megabytes per visit, reaching about 900 megabytes, until contexts are lost. With map.remove on unmount, GPU memory returns to about 60 megabytes after each visit. With one reused map, GPU memory stays around 150 megabytes, the cost of the single live map. 900 MB 0 no map.remove() map.remove() on unmount one reused map (constant) map view visits (0 → 10)

Command and Code Reference

Use case: a Leaflet map owned by a component (Vue).

// useLeafletMap.js — composable
import { onMounted, onBeforeUnmount, shallowRef } from 'vue';
import L from 'leaflet';

export function useLeafletMap(containerRef, options) {
  const map = shallowRef(null);                 // shallow: don't make Leaflet objects reactive
  const markers = new Map();                    // featureId → L.Marker

  onMounted(() => {
    map.value = L.map(containerRef.value, options);
    L.tileLayer('https://tiles.example.com/{z}/{x}/{y}.png').addTo(map.value);
  });

  function syncMarkers(features) {
    const seen = new Set();
    for (const f of features) {
      seen.add(f.id);
      const m = markers.get(f.id);
      if (m) m.setLatLng(f.latlng);
      else markers.set(f.id, L.marker(f.latlng).addTo(map.value));
    }
    for (const [id, m] of markers) {
      if (!seen.has(id)) { m.remove(); markers.delete(id); }   // drop stale markers
    }
  }

  onBeforeUnmount(() => {
    markers.clear();
    map.value?.remove();                         // layers, handlers, container state
    map.value = null;
  });
  return { map, syncMarkers };
}

Use case: a GL map with data updates via a source, removed on unmount (React).

function FleetMap({ vehicles }) {
  const el = useRef(null);
  const map = useRef(null);

  useEffect(() => {
    map.current = new maplibregl.Map({ container: el.current, style: '/style.json' });
    map.current.on('load', () => {
      map.current.addSource('vehicles', { type: 'geojson', data: emptyCollection });
      map.current.addLayer({ id: 'vehicles', type: 'circle', source: 'vehicles' });
    });
    return () => { map.current.remove(); map.current = null; }; // WebGL context, workers, caches
  }, []);

  useEffect(() => {
    // Update data in place instead of re-creating markers or the map
    map.current?.getSource('vehicles')?.setData(toGeoJson(vehicles));
  }, [vehicles]);

  return <div ref={el} className="map" />;
}

Verification and Regression Prevention

Verify each map view with repeated visits: heap and GPU memory return to baseline after leaving (or stay constant with a deliberately reused map), detached map containers do not appear in snapshots, no WebGL context warnings are printed, and marker counts equal data sizes after many refreshes. Test on a mid-range phone, where GPU memory limits are reached much sooner than on desktops.

Keep map creation in one hook, composable or service per app so the teardown path cannot be forgotten, and add an end-to-end test that visits the map route ten times and asserts on heap growth. For point-heavy maps, prefer data sources with setData() or clustering over individual DOM markers — it is both faster and far smaller. The general wrapper contract is described in Third-Party Library Memory Leaks.

Reading memory after leaving a map view After repeated visits to a map view, heap and GPU memory should return to baseline, or stay constant if you deliberately reuse one map. WebGL context warnings mean map.remove was not called and contexts are accumulating. Marker counts above the data size mean markers are added on refresh without clearing the layer. After ten visits to the map view Fixed: map.remove() runs on leave, or one map is reused back to baseline Contexts accumulate: call map.remove() on unmount WebGL context warnings Clear the layer group before re-adding markers markers > data size

Edge Cases and Gotchas

Removing a map before it finished loading

Calling remove() while the style is still loading is supported, but callbacks registered on load may still run afterwards in some versions. Guard them with a check that the map instance still exists.

Hidden containers and resize

A map created in a hidden container has zero size and renders incorrectly; a reused map moved between containers needs map.resize() (GL) or map.invalidateSize() (Leaflet) after it becomes visible. Forgetting this leads developers to re-create maps unnecessarily.

Reactive wrappers around map objects

Making map or layer objects deeply reactive (Vue ref, MobX observables) wraps huge internal structures in proxies. Use shallow references for library instances.

Popups with framework content

Popups that render framework components need those components unmounted when the popup closes; removing the popup DOM does not unmount a React root or Vue app mounted inside it.

Frequently Asked Questions

Does Leaflet clean up when the container is removed?

No. The map instance keeps its layers, handlers and state until you call map.remove(). Removing the container element leaves the instance and the detached container in memory, and re-initialising a map on the same element throws.

Why does my Mapbox or MapLibre map use so much GPU memory?

Each map has its own WebGL context with textures and buffers for tiles, glyphs and sprites, sized by the viewport and device pixel ratio. Without map.remove(), each new map adds another context. Remove maps on unmount or reuse one instance.

Should I create a new map on every route visit?

It is the simplest correct approach if you always call map.remove() on leave. If maps are visited often and creation is slow, keeping one map for the session and moving its container can be faster — at the cost of a constant memory footprint.

How should I update many markers?

Avoid removing and re-adding all markers on every update. Keep them keyed by ID and update positions, add new ones and remove missing ones — or, for large numbers, use a GeoJSON source with setData() or a clustering layer so points are rendered without individual DOM elements.

Can multiple maps share workers?

WebGL map libraries typically share a worker pool among maps on the same page, but each map still has its own WebGL context and caches. Multiple simultaneous maps multiply GPU memory; limit how many are live at once.