My GeoJSON Is Too Big for the Browser

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 59,391 real building polygons:

GeoJSON, as written        27.04 MB
gzipped                     2.65 MB

Gzip does most of the work, and 2.65 MB is still a large download that then has to be parsed into 59,391 JavaScript objects and 410,285 coordinate pairs β€” 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       59,391 -> 3 at zoom 10   (selection)
2. use vector tiles          95.5 kB -> 5.8 kB per tile
3. cut coordinate precision  27.04 MB -> 19.97 MB, gz 2.65 -> 1.11 MB
4. drop unused attributes    varies
# precision: 4 decimal places is about 11 m, 6 is about 11 cm
gdf["geometry"] = gdf.geometry.set_precision(1e-6)
gdf[["name", "building", "geometry"]].to_file("out.geojson", driver="GeoJSON")
Four levers for GeoJSON size: fewer features, vector tiles, coordinate precision and fewer attributes, with their measured effects.
Precision and attributes help. Sending fewer features helps by orders of magnitude.

Step-by-step solution

1. Cut coordinate precision

GeoJSON writes coordinates as decimal text. Seven decimal places is -2.2453891 β€” ten characters per ordinate for a precision of about 11 millimetres.

Measured on the same buildings:

decimals    raw        gzipped
       7   26.50 MB     2.83 MB
       6   25.68 MB     2.43 MB
       5   24.80 MB     1.95 MB
       4   19.97 MB     1.11 MB

Six decimals is about 11 cm and is more than enough for any web map. Four is about 11 m β€” fine for regional views, visible at street level.

Note the gzipped column falls faster than the raw one: shorter numbers are also more repetitive, so they compress better.

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", "category", "geometry"]].to_file(path, 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:

zoom 10:      3 of 59,391 features   (0.0%)
zoom 14: 12,785 of 59,391            (21.5%)
zoom 16: 59,260 of 59,391            (99.8%)

Simplification cannot compete: at zoom 10 it kept 57.9% of vertices while selection kept 0.005% of features.

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.

one zoom-15 tile, 182 buildings
  MVT                 5.80 kB    gz  4.54 kB
  MVT + 2 attributes  8.02 kB    gz  5.73 kB
  the same GeoJSON   95.47 kB    gz 14.72 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.65 MB against 27.04 MB is a factor of ten for one server setting. Any web server does it; check that yours is configured for application/geo+json.

GeoJSON size falling from 26.5 MB to 19.97 MB raw and 2.83 to 1.11 MB gzipped as coordinate precision drops from seven to four decimals.
Shorter numbers compress better too, so the gzipped saving is larger than the raw one.

Code examples

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")

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

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(sum(len(g.exterior.coords)
                       for g in gdf.geometry
                       if g is not None and g.geom_type == "Polygon"))

    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)

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.

Explanation

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 β€” 27.04 MB to 2.65 MB, a factor of ten.

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 2 MB download takes a second. Parsing 59,391 features into JavaScript objects, building 59,391 geometry instances and issuing 59,391 draw calls takes much 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, so even five decimals is finer than the display.

The extra digits cost 3.4% of raw size and 15% of gzipped size 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 building footprints averaging under seven vertices, it kept 57.9% even at 91 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 GeoJSON layer downloading in about a second but creating 59,391 objects and draw calls that block the main thread.
A layer can be small enough to download and still make the page unresponsive.

Edge cases or notes

  • Serve gzipped. A factor of ten for one server setting.
  • Six decimals is 11 cm β€” more than any web map shows.
  • set_precision snaps to a grid and can make geometry invalid; validate afterwards.
  • Feature count matters independently of bytes. Ten thousand features stalls the main thread.
  • Simplification floors out on small polygons at the validity limit.
  • 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, cut coordinate precision to six decimals, and drop unused attributes. Serve it gzipped regardless.

How much does coordinate precision save?

From seven to four decimals took 26.50 MB to 19.97 MB raw, and 2.83 MB to 1.11 MB gzipped β€” the compressed saving is larger because shorter numbers are more repetitive.

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 182 buildings was 4.54 kB gzipped against 14.72 kB as GeoJSON.

Does simplifying the geometry help?

Only for features with many vertices. On building footprints, simplification kept 57.9% of vertices even at 91 m 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.