Handling Large GeoJSON Files in Leafmap Without Browser Lag
Never hand a raw multi-megabyte GeoJSON straight to the client — simplify the geometry, strip non-essential properties, cap the feature count, and cache the result on the server so the browser parses a small payload instead of freezing the main thread.
Jump to heading Why this matters
A spatial dashboard is only as responsive as its heaviest layer. When a user drops a 120 MB administrative-boundary file onto a map, the browser must synchronously parse the entire JSON string and create a DOM or canvas element for every vertex — work that blocks the UI thread and, on constrained tabs, triggers an out-of-memory kill. This is the single most common cause of “the map froze” bug reports in production Streamlit and Panel apps, and it sits at the heart of robust Folium & Leafmap Integration. The fix is architectural: move preprocessing to the server, send the browser only what is visible and simplified, and lean on the broader rendering and state patterns in Spatial Component Integration & Interactive Maps. Because the optimized payload is deterministic for a given input, it is an ideal candidate for the framework caching covered under Caching Strategies & Async Performance Tuning.
Jump to heading Why raw GeoJSON freezes the browser
Leafmap wraps ipyleaflet and folium, which ultimately delegate rendering to Leaflet.js. When a GeoJSON payload exceeds ~15–20 MB, three bottlenecks trigger:
- Synchronous JSON parsing blocks the main thread, freezing UI responsiveness until the entire string is deserialized.
- DOM node explosion occurs as Leaflet creates SVG/Canvas elements for every coordinate and feature. Chromium and WebKit enforce a ~2 GB per-tab memory ceiling, which large geometries quickly exhaust.
- Redundant coordinate precision retains millimeter-level floats that provide zero visual benefit at dashboard zoom levels, inflating payload size by 30–50%.
The GeoJSON specification (RFC 7946) explicitly recommends simplification and coordinate reduction for web delivery. Ignoring this forces the browser to parse and paint geometries that will never be visible at the current viewport scale.
The diagram below contrasts the naive path — handing raw GeoJSON straight to Leaflet.js — with the server-side pipeline that keeps the browser’s main thread responsive.
Jump to heading Prerequisites
- Python 3.9+ — required for the type hints and
geopandasversions used below. geopandas>=0.14andshapely>=2.0— Shapely 2.0 vectorizes the GEOS Douglas-Peucker simplification, making it fast enough to run inside a request.leafmap>=0.30—leafmap.foliumapfor Folium-backed rendering, plusadd_geojson()andadd_tile_layer().streamlit>=1.30(orpanel>=1.3) — to host the map and provide the cache decorator.tippecanoe— only needed for the vector-tile path in Step 6; install it from your package manager (e.g.brew install tippecanoe).- One conceptual prerequisite: a working grasp of CRS normalization, because simplification tolerance is unit-sensitive — reproject to a single known CRS before you measure or simplify (see the prerequisites in Folium & Leafmap Integration).
Install the runtime dependencies:
pip install "geopandas>=0.14" "shapely>=2.0" "leafmap>=0.30" streamlit
Jump to heading Step-by-step solution
Jump to heading Step 1 — Profile the payload before optimizing
Measure before you cut. The decision to simplify, cap, or switch to tiles depends on three numbers: feature count, on-disk size, and total vertices.
import geopandas as gpd
import os
def profile_geojson(path: str) -> dict:
"""Return the three metrics that drive the optimization decision."""
gdf = gpd.read_file(path)
vertices = int(gdf.geometry.apply(lambda g: len(g.exterior.coords)
if g.geom_type == "Polygon" else
sum(len(p.exterior.coords) for p in g.geoms)
if g.geom_type == "MultiPolygon" else 0).sum())
return {
"features": len(gdf),
"size_mb": round(os.path.getsize(path) / 1_048_576, 1),
"vertices": vertices,
"crs": str(gdf.crs),
}
print(profile_geojson("us_counties_detailed.geojson"))
# {'features': 3221, 'size_mb': 118.4, 'vertices': 5_412_880, 'crs': 'EPSG:4326'}
A file with 3,221 features but 5.4 million vertices is a precision problem, not a feature-count problem — the win comes from simplification, not feature capping.
Jump to heading Step 2 — Normalize the CRS to WGS 84
GeoPandas simplify() measures tolerance in the units of the active CRS. Run it on a projected CRS such as EPSG:3857 (metres) and a degree-scale tolerance will either do nothing or destroy your geometry. Reproject to EPSG:4326 first.
def to_wgs84(gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
if gdf.crs is None:
raise ValueError("GeoDataFrame has no CRS; set one before simplifying")
if gdf.crs.to_epsg() != 4326:
gdf = gdf.to_crs(epsg=4326)
return gdf
Jump to heading Step 3 — Simplify geometry and strip properties
Use topology-preserving Douglas-Peucker simplification, then drop every column the map does not render. A tolerance of 0.001 degrees is roughly 100 m at mid-latitudes — invisible at dashboard zoom levels but a 30–60% size reduction.
The reason CRS normalization (Step 2) has to come first is that simplify() reads its tolerance in whatever unit the active CRS uses. The same number means three completely different things depending on the projection:
def slim_down(gdf: gpd.GeoDataFrame, tolerance: float = 0.001,
keep: tuple = ("id", "name", "value")) -> gpd.GeoDataFrame:
# Topology-preserving simplification (GEOS Douglas-Peucker)
gdf = gdf.copy()
gdf["geometry"] = gdf.geometry.simplify(tolerance, preserve_topology=True)
# Keep only geometry + the columns the UI actually uses
cols = ["geometry"] + [c for c in keep if c in gdf.columns]
return gdf[cols]
Jump to heading Step 4 — Cap client features and cache the whole pipeline
Compose Steps 1–3 into a single cached function. The cache makes the expensive geopandas I/O and GEOS work run once per TTL instead of on every Streamlit rerun — exactly the kind of deterministic, reusable result that belongs under Caching Strategies & Async Performance Tuning.
import streamlit as st
@st.cache_data(ttl=3600, max_entries=5, show_spinner="Optimizing GeoJSON…")
def optimize_geojson(path: str, tolerance: float = 0.001,
max_features: int = 80_000) -> str:
"""Load → CRS-normalize → simplify → strip → cap → write. Returns cache path."""
gdf = gpd.read_file(path)
gdf = to_wgs84(gdf)
if len(gdf) > max_features:
# For unordered data, sample rather than head-truncate to avoid spatial bias
gdf = gdf.sample(max_features, random_state=0).copy()
gdf = slim_down(gdf, tolerance=tolerance)
cache_dir = "/tmp/geojson_cache"
os.makedirs(cache_dir, exist_ok=True)
out = os.path.join(cache_dir, f"opt_{os.path.basename(path)}")
gdf.to_file(out, driver="GeoJSON")
return out
The ~80,000-feature cap exists because that is roughly where Leaflet’s per-feature DOM and event-handler overhead starts producing scroll jank, independent of payload size.
Jump to heading Step 5 — Render the optimized layer in Leafmap
Point Leafmap at the cached file and embed it. Because the heavy work is upstream, the map renders from a small payload.
import leafmap.foliumap as leafmap
st.title("Optimized Spatial Dashboard")
uploaded = st.file_uploader("Upload GeoJSON", type=["geojson"])
if uploaded:
temp_path = os.path.join("/tmp", uploaded.name)
with open(temp_path, "wb") as f:
f.write(uploaded.getbuffer())
optimized_path = optimize_geojson(temp_path, tolerance=0.001)
m = leafmap.Map(center=[40.0, -100.0], zoom=4)
m.add_geojson(optimized_path, layer_name="Optimized Features")
m.to_streamlit(height=600)
For Panel, the same optimize_geojson() output feeds m.to_panel() (or assign the Folium map to a pn.pane.plot.Folium pane) and .servable().
Jump to heading Step 6 — Switch to vector tiles when GeoJSON is not enough
Past ~50 MB of simplified geometry, even a trimmed GeoJSON strains client memory. Pre-bake Mapbox Vector Tiles (MVT) with tippecanoe, which clips geometry per tile and per zoom level so the browser only paints the current viewport.
tippecanoe -o tiles.mbtiles --drop-densest-as-needed --maximum-zoom=14 input.geojson
Serve the tiles from a lightweight HTTP server or object storage, then render the {z}/{x}/{y}.pbf endpoints:
m.add_tile_layer(
url="https://your-server/tiles/{z}/{x}/{y}.pbf",
name="Vector Tiles",
attribution="Custom MVT",
)
For user-filtered or streaming data, an alternative is chunked delivery: split the dataset into 10k-feature batches and append them to the Leaflet layer progressively with L.geoJSON().addData(), keeping the main thread free between chunks.
Jump to heading Verification
Confirm the optimization actually shrank the payload and stayed within the feature cap before you ship:
before = profile_geojson("us_counties_detailed.geojson")
optimized_path = optimize_geojson("us_counties_detailed.geojson", tolerance=0.001)
after = profile_geojson(optimized_path)
assert after["features"] <= 80_000, "Feature cap was not enforced"
assert after["size_mb"] < before["size_mb"], "Payload did not shrink"
assert after["crs"] == "EPSG:4326", "Output is not in WGS 84"
reduction = 100 * (1 - after["size_mb"] / before["size_mb"])
print(f"{before['size_mb']} MB → {after['size_mb']} MB ({reduction:.0f}% smaller)")
# Expected: 118.4 MB → 11.7 MB (90% smaller)
A healthy result is a payload comfortably under 15 MB with the feature count at or below the cap. In the browser, open DevTools → Performance, record a map load, and confirm there is no long task (>50 ms) blocking the main thread during render.
Jump to heading Edge cases and gotchas
- Simplifying in the wrong CRS: Running
simplify(0.001)on a GeoDataFrame still in EPSG:3857 (metres) collapses sub-millimetre detail to nothing useful or leaves the geometry untouched. Always apply CRS normalization to EPSG:4326 (or express the tolerance in the projected unit) before Step 3 — this is the most common source of “my polygons vanished” bugs. - Topology gaps between adjacent polygons:
preserve_topology=Trueprevents self-intersections within a geometry but does not keep shared borders between neighbouring features aligned, producing visible slivers. For coverage data (counties, watersheds), use a topology-aware tool such asmapshaper -simplifyor TopoJSON instead of per-featuresimplify(). - Cache TTL serving stale geometry:
@st.cache_data(ttl=3600)keys on the file path and arguments, not file contents. If a user re-uploads a changed file under the same name, the stale optimized copy is returned until the TTL expires. Include a content hash (e.g.hashlib.md5(open(path,'rb').read()).hexdigest()) in the cache key when inputs can change in place — the same cache-invalidation discipline covered in Caching Strategies & Async Performance Tuning.
Jump to heading FAQ
What is the maximum number of GeoJSON features Leafmap can render smoothly?
As a practical rule, keep client-side features under roughly 80,000 and the serialized payload under 15–20 MB. Beyond that, Leaflet creates a DOM/event handler per feature, which produces scroll jank and risks exhausting the per-tab memory ceiling. For larger datasets, enable canvas rendering, simplify more aggressively, or move to Mapbox Vector Tiles as shown in Step 6.
Why does simplify() distort my geometries?
simplify() interprets tolerance in the units of the active CRS. In a projected CRS such as EPSG:3857 (metres) a tolerance of 0.001 does almost nothing, while a degree-tuned tolerance can collapse projected geometry entirely. Reproject to EPSG:4326 before simplifying, or express the tolerance in the projected unit, and always pass preserve_topology=True to avoid self-intersections.
Should I use add_geojson or vector tiles for a 200 MB dataset?
Use vector tiles. At 200 MB the dataset is far past the point where even simplified GeoJSON fits comfortably in browser memory. Pre-bake Mapbox Vector Tiles with tippecanoe, serve the {z}/{x}/{y}.pbf endpoints, and render them with add_tile_layer(). Tiles clip geometry to the viewport and zoom level, so the browser only ever paints what is visible.
Back to Folium & Leafmap Integration
Related
- Folium & Leafmap Integration — parent guide to wiring Folium and Leafmap into Streamlit and Panel dashboards
- Spatial Component Integration & Interactive Maps — the rendering, state, and event patterns behind every interactive map component
- Syncing Deck.gl Layers with Streamlit State Variables — GPU-backed rendering for datasets too large even for vector tiles
- Caching Strategies & Async Performance Tuning — cache-key design, TTLs, and async loading for heavy spatial payloads