My GeoJSON Is Too Big for the Browser
On this page
Problem statement
A GeoJSON layer that works in a desktop GIS makes a web map unusable. The page hangs on load, panning stutters, and mobile devices run out of memory.
Measured on Leiden's 58,879 OpenStreetMap buildings (Example 1):
GeoJSON, as written 20.80 MB
gzipped 2.98 MB
Gzip does most of the work, and 2.98 MB is still a large download that then has to be parsed into 58,879 JavaScript objects and 434,355 coordinate pairs (Example 3) β which is where the browser actually struggles.
There are four levers, and they are worth very different amounts.
Quick answer
In order of effect:
1. send fewer features 58,879 -> 1 at zoom 10, 3,606 at zoom 14 (selection)
2. use vector tiles 956 kB -> 71 kB for one zoom-15 tile
3. cut coordinate precision gz 2.98 -> 2.51 MB at six decimals
4. drop unused attributes gz 2.98 -> 2.86 MB with none at all
import geopandas as gpd
gdf = gpd.read_parquet("buildings.parquet")
# precision: 6 decimal places is about 11 cm; 4 is about 11 m, and deletes buildings
gdf["geometry"] = gdf.geometry.set_precision(1e-6)
gdf[["name", "building", "geometry"]].to_file("out.geojson", driver="GeoJSON")
print(f"{len(gdf):,} buildings, {gdf.geometry.is_empty.sum()} emptied by rounding")
58,879 buildings, 0 emptied by rounding
Precision, attributes, features, then vector tiles
1. Cut coordinate precision
GeoJSON writes coordinates as decimal text. Seven decimal places is 4.4738351 β nine characters per ordinate for a precision of about 11 millimetres.
Measured on the same buildings (Example 1):
decimals raw gzipped buildings emptied
7 20.80 MB 2.98 MB 0
6 19.92 MB 2.51 MB 0
5 18.57 MB 1.89 MB 46
4 11.55 MB 0.71 MB 32,384
Six decimals is about 11 cm and is more than enough for any web map. Four is about 11 m, and on buildings it is not a precision setting but a deletion: a footprint smaller than the grid snaps to nothing, and 32,384 of Leiden's 58,879 buildings did. The smaller file at four decimals is mostly the buildings that are no longer in it.
OpenStreetMap stores coordinates to seven decimals, so seven is the data as it came.
2. Drop attributes you do not use
Every property is written per feature with its full key name. A layer with twenty attributes styled by one is carrying nineteen for nothing.
gdf[["name", "building", "geometry"]].to_file("trimmed.geojson", driver="GeoJSON")
Where detail is needed on click, send an identifier and fetch the rest on demand.
3. Send fewer features
This is the lever that changes the order of magnitude. A feature smaller than about two screen pixels renders as nothing and costs full price.
Measured, dropping buildings below two-by-two pixels, and simplifying at one pixel instead for comparison:
import math
utm = gdf.to_crs(gdf.estimate_utm_crs())
lat = float(gdf.geometry.centroid.y.mean())
vertices = utm.geometry.count_coordinates().sum()
for zoom in (10, 14, 16):
m_per_px = 156543.03392 * math.cos(math.radians(lat)) / 2 ** zoom
kept = (utm.geometry.area >= (2 * m_per_px) ** 2).sum()
simplified = utm.geometry.simplify(m_per_px, preserve_topology=True).count_coordinates().sum()
print(f"zoom {zoom}: {kept:6,} of {len(utm):,} features ({kept / len(utm):5.1%}); "
f"simplifying at {m_per_px:5.1f} m keeps {simplified / vertices:.1%} of vertices")
zoom 10: 1 of 58,879 features ( 0.0%); simplifying at 93.8 m keeps 54.5% of vertices
zoom 14: 3,606 of 58,879 features ( 6.1%); simplifying at 5.9 m keeps 56.7% of vertices
zoom 16: 43,753 of 58,879 features (74.3%); simplifying at 1.5 m keeps 74.4% of vertices
Simplification cannot compete: at zoom 10 it still kept most of the vertices, because a valid polygon cannot lose many of the handful a building has, while selection removed nearly every feature.
4. Switch to vector tiles when the layer is large
At some point per-feature filtering stops being enough and the answer is a tile pyramid, where the client only loads the current view.
Measured on one zoom-15 tile in the centre of Leiden (Example 4):
zoom-15 tile 16793/10800, 2699 buildings
MVT 71.28 kB gz 46.89 kB
MVT + 2 attributes 88.14 kB gz 49.26 kB
the same GeoJSON 956.32 kB gz 130.28 kB
The threshold is roughly: a few thousand features is fine as GeoJSON, tens of thousands is borderline, hundreds of thousands needs tiles.
5. Serve it compressed, always
2.98 MB against 20.80 MB is a factor of seven for one server setting. Any web server does it; check that yours is configured for application/geo+json.
Where the bytes go in Leiden's buildings
The data used on this page
The layer is every building in Leiden in OpenStreetMap β about 59,000 footprints with their building type and, where mapped, a name, height and number of levels β fetched with OSMnx through an Overpass mirror and saved as GeoParquet in EPSG:4326. The OpenStreetMap answers are read as the Overpass mirror gave them on 10 October 2026.
import io
import os
import requests
import zipfile
import geopandas as gpd
import osmnx as ox
# OpenStreetMap as Overpass returned it on 10 October 2026, saved as osmnx's own cache so the counts
# below do not drift as OSM is edited; delete the cache folder to query live instead
if not os.path.exists("cache"):
reply = requests.get("https://data.spatialworkflow.io/osm-cache-geojson-too-big-for-browser-2026-10-10.zip", timeout=300)
reply.raise_for_status()
with zipfile.ZipFile(io.BytesIO(reply.content)) as archive:
archive.extractall("cache")
ox.settings.overpass_url = "https://maps.mail.ru/osm/tools/overpass/api" # overpass-api.de mirror
ox.settings.overpass_rate_limit = False
if not os.path.exists("buildings.parquet"):
found = ox.features_from_place("Leiden, Netherlands", {"building": True}).reset_index()
found = found[found.geom_type.isin(["Polygon", "MultiPolygon"])]
keep = [c for c in ("osmid", "building", "name", "height", "building:levels") if c in found.columns]
found[keep + ["geometry"]].rename(columns={"building:levels": "levels"}).to_parquet("buildings.parquet")
buildings = gpd.read_parquet("buildings.parquet")
print(f"{len(buildings):,} OSM buildings in Leiden, EPSG:{buildings.crs.to_epsg()}; "
f"columns {[c for c in buildings.columns if c != 'geometry']}")
58,879 OSM buildings in Leiden, EPSG:4326; columns ['building', 'name', 'height', 'levels']
Example 1 β measuring where the bytes go
import gzip
import json
def geojson_budget(gdf, decimals=(7, 6, 5, 4), keep_columns=None):
"""How much do precision and attributes cost, in this dataset?"""
full = gdf.to_json()
print(f" as written {len(full) / 1e6:7.2f} MB "
f"gz {len(gzip.compress(full.encode(), 6)) / 1e6:6.2f} MB")
if keep_columns:
trimmed = gdf[list(keep_columns) + ["geometry"]].to_json()
print(f" {len(keep_columns)} attributes only "
f"{len(trimmed) / 1e6:7.2f} MB "
f"gz {len(gzip.compress(trimmed.encode(), 6)) / 1e6:6.2f} MB")
geometry_only = gdf[["geometry"]].to_json()
print(f" geometry only {len(geometry_only) / 1e6:7.2f} MB "
f"gz {len(gzip.compress(geometry_only.encode(), 6)) / 1e6:6.2f} MB")
for places in decimals:
reduced = gdf.copy()
reduced["geometry"] = reduced.geometry.set_precision(10 ** -places)
text = reduced.to_json()
print(f" {places} decimals {len(text) / 1e6:7.2f} MB "
f"gz {len(gzip.compress(text.encode(), 6)) / 1e6:6.2f} MB "
f"{reduced.geometry.is_empty.sum():,} emptied")
geojson_budget(gpd.read_parquet("buildings.parquet"), keep_columns=("building",))
as written 20.80 MB gz 2.98 MB
1 attributes only 18.09 MB gz 2.92 MB
geometry only 16.99 MB gz 2.86 MB
7 decimals 20.80 MB gz 2.98 MB 0 emptied
6 decimals 19.92 MB gz 2.51 MB 0 emptied
5 decimals 18.57 MB gz 1.89 MB 46 emptied
4 decimals 11.55 MB gz 0.71 MB 32,384 emptied
Running this once tells you which lever is worth pulling for your data. A layer of long coastlines is dominated by coordinates; a layer of points with rich attributes is dominated by properties.
Example 2 β a web-ready export
import math
import geopandas as gpd
def export_for_web(gdf, path, zoom=14, lat=None, keep_columns=(),
decimals=6, min_visible_px=2.0):
"""Select, simplify, trim and round in one step."""
lat = lat if lat is not None else float(gdf.geometry.centroid.y.mean())
m_per_px = 156543.03392 * math.cos(math.radians(lat)) / (2 ** zoom)
utm = gdf.to_crs(gdf.estimate_utm_crs())
keep = utm.geometry.area >= (min_visible_px * m_per_px) ** 2
subset = utm[keep].copy()
subset["geometry"] = subset.geometry.simplify(m_per_px,
preserve_topology=True)
out = subset.to_crs(4326)
out = out[list(keep_columns) + ["geometry"]]
out["geometry"] = out.geometry.set_precision(10 ** -decimals)
out.to_file(path, driver="GeoJSON")
import os
print(f" {len(gdf):,} -> {len(out):,} features "
f"({len(out) / len(gdf):.1%}) at z{zoom}")
print(f" {os.path.getsize(path) / 1e6:.2f} MB with {decimals} decimals "
f"and {len(keep_columns)} attributes")
return path
export_for_web(gpd.read_parquet("buildings.parquet"), "web_z14.geojson", zoom=14, keep_columns=("building",))
58,879 -> 3,603 features (6.1%) at z14
0.96 MB with 6 decimals and 1 attributes
The order matters: select before simplifying (no point simplifying what you will drop), simplify before reprojecting to 4326 (tolerances are metres), and round last.
Example 3 β deciding whether you need tiles
def needs_tiles(gdf, budget_mb=2.0, budget_features=10_000):
"""Is this layer viable as a single GeoJSON?"""
import gzip
text = gdf.to_json()
compressed = len(gzip.compress(text.encode(), 6)) / 1e6
vertices = int(gdf.geometry.count_coordinates().sum())
print(f" {len(gdf):,} features, {vertices:,} vertices, "
f"{compressed:.2f} MB gzipped")
reasons = []
if compressed > budget_mb:
reasons.append(f"{compressed:.1f} MB exceeds the {budget_mb} MB budget")
if len(gdf) > budget_features:
reasons.append(f"{len(gdf):,} features will be slow to parse and render")
if reasons:
print(" -> use vector tiles: " + "; ".join(reasons))
else:
print(" -> a single GeoJSON is viable")
return bool(reasons)
needs_tiles(gpd.read_parquet("buildings.parquet"))
58,879 features, 434,355 vertices, 2.98 MB gzipped
-> use vector tiles: 3.0 MB exceeds the 2.0 MB budget; 58,879 features will be slow to parse and render
The feature count matters independently of the size. Ten thousand small features gzip well and still create ten thousand DOM nodes or WebGL draw calls, which is where the browser stalls.
Example 4 β one tile, three encodings
import gzip
import json
import mapbox_vector_tile
import mercantile
from shapely.geometry import box
buildings = gpd.read_parquet("buildings.parquet")
tile = mercantile.tile(4.4938, 52.1585, 15) # central Leiden
west, south, east, north = mercantile.bounds(tile)
in_tile = buildings.iloc[buildings.sindex.query(box(west, south, east, north))]
in_tile = in_tile[in_tile.intersects(box(west, south, east, north))]
bounds = mercantile.xy_bounds(tile) # the tile in Web Mercator metres
merc = in_tile.to_crs(3857).clip(box(*bounds))
def encode(properties):
features = [{"geometry": g.wkt, "properties": {k: row[k] for k in properties if row[k] is not None}}
for g, (_, row) in zip(merc.geometry, merc.iterrows())]
return mapbox_vector_tile.encode([{"name": "buildings", "features": features}],
default_options={"quantize_bounds": tuple(bounds), "extents": 4096})
geojson = in_tile[["building", "name", "geometry"]].to_json().encode()
print(f"zoom-15 tile {tile.x}/{tile.y}, {len(in_tile)} buildings")
for label, data in (("MVT", encode(())), ("MVT + 2 attributes", encode(("building", "name"))),
("the same GeoJSON", geojson)):
print(f" {label:20} {len(data) / 1e3:7.2f} kB gz {len(gzip.compress(data, 6)) / 1e3:6.2f} kB")
zoom-15 tile 16793/10800, 2699 buildings
MVT 71.28 kB gz 46.89 kB
MVT + 2 attributes 88.14 kB gz 49.26 kB
the same GeoJSON 956.32 kB gz 130.28 kB
The tile wins twice over: coordinates become small integers on a 4,096-unit grid, and geometry outside the tile is clipped away, while the GeoJSON carries every building that touches the tile whole.
Gzip does most of the work
Why gzip does most of the work
GeoJSON is text, and text with enormous redundancy: {"type":"Feature","properties":{...},"geometry":{"type":"Polygon","coordinates":[[[, repeated per feature.
Gzip removes almost all of that structural repetition β 20.80 MB to 2.98 MB, a factor of seven.
What it cannot remove is the coordinate digits, which are close to random. That is why cutting precision helps after gzip: fewer digits is genuinely less entropy, not just less text.
Why the feature count matters more than the byte count
A 3 MB download takes a second or two. Parsing 58,879 features into JavaScript objects, building 58,879 geometry instances and issuing their draw calls takes longer, and it happens on the main thread.
That is why a layer can be small enough to download and still make the page unresponsive. The fix is fewer features, not smaller ones.
Vector tiles solve it structurally: the client only ever holds the features in the current view.
Why precision beyond six decimals is waste
Six decimal places of longitude is about 11 cm at the equator and less at higher latitudes. Seven is about 1 cm.
No web map displays 1 cm. At zoom 18 β a very close view β one pixel is about 36 cm at Leiden's latitude, so even five decimals is finer than the display.
The seventh digit costs 0.88 MB raw and 0.47 MB gzipped β 16% of the compressed file β for information nobody can see.
Why simplification is not the answer here
simplify(preserve_topology=True) cannot reduce a polygon below the vertex count a valid polygon needs. On Leiden's building footprints it kept 54.5% of the vertices even at zoom 10's 93.8 m tolerance.
Simplification is the right tool for coastlines, rivers and administrative boundaries with thousands of vertices. For dense small polygons the lever is selection.
Empties, ids, TopoJSON and tiles
set_precisionsnaps to a grid and can empty small polygons outright; count empties afterwards.- Send an id and fetch details on click rather than every attribute.
- TopoJSON helps for shared boundaries β administrative units β and not for detached polygons.
- Above tens of thousands of features, use tiles.
Internal links
- Generalisation for zoom levels explained β why selection beats simplification
- How to prepare a GeoDataFrame for the web β the full export checklist
- Vector tiles explained: MVT, layers and why they are not GeoJSON β the structural fix
- How to build vector tiles from a GeoDataFrame in Python β producing them
- My web map is blank or the layer never appears β when it fails rather than stalls
- How to reduce GIS file size in Python without wrecking the data β the general case
- How to export GeoJSON in Python with GeoPandas β the export itself
- Coordinate precision and floating point in GIS explained β what precision means
FAQ
How do I make my GeoJSON smaller?
In order of effect: send fewer features, use vector tiles, drop unused attributes, and cut coordinate precision to six decimals β no further. Serve it gzipped regardless.
How much does coordinate precision save?
Seven to six decimals saved 0.88 MB raw and 0.47 MB gzipped. Going on to four looks like a bigger saving and is not one: on Leiden's buildings, 32,384 footprints snapped to nothing, and the file shrank mostly because they were gone.
How many decimal places do I need?
Six, which is about 11 cm. At zoom 18 one screen pixel is roughly 36 cm, so more is invisible.
Why is my map slow even though the file is small?
Feature count, not bytes. Parsing and rendering tens of thousands of features blocks the main thread regardless of the download size.
When should I switch to vector tiles?
Above roughly ten thousand features or 2 MB gzipped. One zoom-15 tile of central Leiden, 2,699 buildings, was 46.89 kB gzipped as MVT against 130.28 kB as GeoJSON.
Does simplifying the geometry help?
Only for features with many vertices. On Leiden's building footprints, simplification kept 54.5% of the vertices even at zoom 10's tolerance.
Is TopoJSON worth using?
For layers with shared boundaries β administrative units β yes, because shared edges are stored once. For detached polygons it saves little.