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
Four levers for GeoJSON size: fewer features, vector tiles, coordinate precision and fewer attributes, with their measured effects. what each lever is actually worthas written20.80 MBgzipped2.98 MB6 decimals, gzipped2.51 MBone z15 tile, MVT gz46.9 kB022 MBPrecision and attributes help; sending fewer features helps by orders of magnitude spatialworkflow.io
Precision and attributes help. Sending fewer features helps by orders of magnitude.

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.

Gzipped GeoJSON size at four coordinate precisions, with the ground resolution each one keeps. coordinate precision, gzipped sizedecimalsground resolutiongzippedbuildings lost711 mm2.98 MB0611 cm2.51 MB051.1 m1.89 MB46411 m0.71 MB32,384OpenStreetMap stores seven decimals, so seven is the data as it cameBelow six decimals the saving starts to come from buildings that are no longer there spatialworkflow.io
Six decimals is the floor for building footprints; below it, rounding deletes them.

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.

A layer small enough to download but too large to parse and render on the main thread. the feature count matters independently of the bytesdownloadβœ“2.98 MB gzippedβœ“a second or twoβœ“not the bottleneckβœ“fine on most connectionsparse and renderβœ•58,879 JavaScript objectsβœ•434,355 coordinate pairsβœ•58,879 geometries to drawβœ•blocks the main threadAbove roughly ten thousand features, the fix is tiles rather than a smaller file spatialworkflow.io
A layer can be small enough to download and still make the page unresponsive.

Empties, ids, TopoJSON and tiles

  • set_precision snaps 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.

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.