Optimizing GeoJSON Payloads for APIs

For APIs returning vector features, the recommended sequence is: trim coordinate precision to 5–6 decimal places, strip unused properties via server-side projection, apply bounding-box clipping before serialization, then compress responses with Brotli at quality 4–6. Applied in order, these four measures typically reduce payload size by 70–90% on realistic feature collections. When client-side JSON parsing latency itself becomes the bottleneck — measurable only after profiling — migrating to a binary format such as FlatGeobuf is the correct next step. This page sits under GeoJSON Overhead and Serialization Costs, which profiles the underlying CPU, memory, and parse-latency costs these fixes target.

Quick-Reference Table

Bottleneck Primary Fix Typical Payload Reduction Primary Use Case
Excessive decimal places Round to 5–6 precision 30–40% Any API returning polygon or point data from GDAL/PostGIS export
Unused properties Server-side column projection 15–25% Endpoints where clients use 2–3 fields from a 20-column schema
Full-dataset transfers Bounding-box filter + spatial index 70–95% (viewport-dependent) Tile and viewport APIs where most features fall outside the current view
Text serialization overhead Brotli/gzip + chunked streaming 50–70% Any HTTP API response; free with modern reverse proxies
Client JSON parse latency FlatGeobuf or GeoParquet 60–80% vs JSON Clients where parse time exceeds 200 ms after all format-preserving fixes

Apply these in order from top to bottom. The first three are structural changes that reduce data volume before compression even starts; jumping straight to compression leaves most of the gain on the table.

Why GeoJSON Carries Structural Overhead

GeoJSON’s text-based structure repeats the same keys for every feature. A FeatureCollection with 10,000 points emits "type":"Feature","geometry":{"type":"Point","coordinates":[ once per feature — roughly 60 bytes of structural boilerplate per record before any coordinate values appear. For polygon datasets, nested coordinate arrays multiply this overhead across every ring vertex.

Floating-point precision compounds the problem. Standard GIS export tools — QGIS, GDAL, PostGIS default output — serialise coordinates with 15 significant digits: -73.98473829475920. For web mapping, routing, or analytics, six decimal places — -73.984738 — are indistinguishable. The extra nine digits add approximately 9 bytes per coordinate value, and a complex polygon can hold thousands of vertices.

The companion analysis of GeoJSON Overhead and Serialization Costs profiles CPU time, memory allocation, and parse latency across feature counts in detail. The optimisations below target the specific bottlenecks that profiling surfaces, in order of impact.

Cumulative GeoJSON payload reduction Waterfall bar chart showing payload size remaining as a percentage after each optimisation step. Baseline is 100%. After precision trim: 62%. After spatial filter: 28%. After Brotli compression: 7%. % of original payload remaining 0% 25% 50% 75% 100% 100% Baseline 62% + Precision trim −38% 28% + Spatial filter −34% 7% + Brotli −21%
Illustrative cumulative reduction applying precision trimming, spatial filtering, and Brotli compression in sequence on a typical 50 MB polygon FeatureCollection. Each bar shows the payload fraction remaining; labels below show the reduction from the previous step.

Production Implementation

The snippet below handles precision reduction, whitespace stripping, and optional spatial filtering in a single serialization pass. It uses orjson for performance — 3–5× faster than stdlib json for large payloads — and documents every decision inline:

python
# Requires: orjson>=3.9, shapely>=2.0
# Python 3.11+
from __future__ import annotations

import orjson
from typing import Any


def _round_coords(obj: Any, precision: int) -> Any:
    """Recursively round coordinate values. Handles Points, LineStrings, Polygons,
    and Multi-geometry arrays. Does not mutate the input object."""
    if isinstance(obj, list):
        # Coordinate leaf: list of numbers with 2 or 3 elements
        if 2 <= len(obj) <= 3 and all(isinstance(v, (int, float)) for v in obj):
            return [round(v, precision) for v in obj]
        return [_round_coords(item, precision) for item in obj]
    if isinstance(obj, dict):
        return {k: _round_coords(v, precision) for k, v in obj.items()}
    return obj


def _project_properties(
    feature: dict[str, Any],
    keep_fields: set[str] | None,
) -> dict[str, Any]:
    """Drop properties not in keep_fields. If keep_fields is None, keep all."""
    if keep_fields is None or "properties" not in feature:
        return feature
    props = feature.get("properties") or {}
    return {**feature, "properties": {k: v for k, v in props.items() if k in keep_fields}}


def _flatten_coords(coords: Any) -> list[list[float]]:
    """Flatten arbitrarily nested coordinate arrays to a list of [lon, lat] pairs."""
    if not coords:
        return []
    if isinstance(coords[0], (int, float)):
        return [coords]  # type: ignore[list-item]
    result: list[list[float]] = []
    for item in coords:
        result.extend(_flatten_coords(item))
    return result


def serialize_geojson(
    collection: dict[str, Any],
    *,
    precision: int = 6,
    keep_fields: set[str] | None = None,
    bbox: tuple[float, float, float, float] | None = None,
) -> bytes:
    """
    Serialize a GeoJSON FeatureCollection to minified JSON bytes.

    Args:
        collection: A GeoJSON FeatureCollection dict.
        precision: Coordinate decimal places (default 6 ≈ 11 cm accuracy).
        keep_fields: If set, only these property keys are included per feature.
        bbox: Optional (min_lon, min_lat, max_lon, max_lat) for client-side
              bounding-box filter — prefer server-side ST_Intersects for large
              datasets; this is a final safety net only.

    Returns:
        Minified UTF-8 JSON bytes ready for HTTP response or S3 upload.
    """
    features: list[dict[str, Any]] = collection.get("features", [])

    if bbox is not None:
        min_lon, min_lat, max_lon, max_lat = bbox
        filtered = []
        for f in features:
            coords = f.get("geometry", {}).get("coordinates", [])
            # Fast centroid check — adequate for bounding-box pre-filter
            flat = _flatten_coords(coords)
            if flat:
                lon = sum(c[0] for c in flat) / len(flat)
                lat = sum(c[1] for c in flat) / len(flat)
                if min_lon <= lon <= max_lon and min_lat <= lat <= max_lat:
                    filtered.append(f)
        features = filtered

    rounded_features = [
        _round_coords(_project_properties(f, keep_fields), precision)
        for f in features
    ]

    output = {**collection, "features": rounded_features}
    # orjson serialises floats without unnecessary trailing zeros:
    # -73.984738 not -73.9847380000000
    return orjson.dumps(output, option=orjson.OPT_NON_STR_KEYS)

Implementation notes:

  • orjson.dumps omits trailing zeros from floats (-73.984738 not -73.98473800000), shaving a further 5–10% versus stdlib json.
  • keep_fields is the server-side equivalent of a SQL SELECT projection. Define it per endpoint: a routing API might only need id and speed_limit; a choropleth map might need id and value.
  • The bbox parameter provides a last-resort client-side filter. For datasets with more than ~10,000 features, push this filter into your database using ST_Intersects(geom, ST_MakeEnvelope($1,$2,$3,$4,4326)) on a GiST-indexed geometry column — never load the full table into Python memory first.

Validation and Benchmarking

Run the harness below against your endpoint before and after applying each optimisation. It measures transfer size, parse time, and peak memory, giving you concrete numbers for each change rather than relying on rule-of-thumb estimates:

python
# Requires: requests>=2.31, psutil>=5.9, orjson>=3.9
# Python 3.11+
import time
import tracemalloc

import orjson
import requests


def benchmark_geojson_endpoint(url: str, *, iterations: int = 5) -> dict[str, float]:
    """
    Measure transfer size, parse time, and memory for a GeoJSON endpoint.

    Returns a dict with keys: transfer_kb, parse_ms_mean, peak_mem_kb.
    """
    parse_times: list[float] = []
    peak_mems: list[int] = []
    transfer_size: int = 0

    for _ in range(iterations):
        resp = requests.get(url, headers={"Accept-Encoding": "br, gzip"}, timeout=30)
        resp.raise_for_status()
        raw = resp.content
        transfer_size = len(raw)  # compressed bytes on the wire

        tracemalloc.start()
        t0 = time.perf_counter()
        _ = orjson.loads(raw)
        parse_ms = (time.perf_counter() - t0) * 1000
        _, peak = tracemalloc.get_traced_memory()
        tracemalloc.stop()

        parse_times.append(parse_ms)
        peak_mems.append(peak)

    return {
        "transfer_kb": transfer_size / 1024,
        "parse_ms_mean": sum(parse_times) / len(parse_times),
        "peak_mem_kb": max(peak_mems) / 1024,
    }

Expected output ranges after full optimisation:

Metric Before After (all four fixes) Primary Use Case
Transfer size 50 MB 3–5 MB Any production GeoJSON endpoint
Parse time (p50) 800 ms 60–120 ms Web map tile APIs
Peak client memory 380 MB 28–50 MB Mobile and low-memory browser clients

If parse time remains above 200 ms after all format-preserving optimisations, that is the signal to evaluate binary formats — covered in the edge cases section below.

Edge Cases and Caveats

High-altitude and zoomed-out views. Bounding-box filtering alone is insufficient when a single viewport covers a continent. At zoom levels below 8, apply server-side geometry simplification — ST_Simplify in PostGIS, or Shapely’s simplify() method — to reduce vertex counts before serialization. A polygon with 50,000 vertices rendered at continental scale is perceptually indistinguishable from one with 200 vertices.

Mixed-CRS sources. If your pipeline ingests geometries from multiple coordinate reference systems before converting to WGS84, never round coordinates before the final re-projection step. Sub-metre rounding errors in a projected CRS (e.g. EPSG:27700) can produce multi-metre displacement after transformation. Always reproject first, then round the WGS84 output.

Streaming large FeatureCollections. Chunked transfer encoding removes the memory ceiling for large responses, but you must not buffer the full collection before emitting the first chunk. Use a generator-based serializer that emits the FeatureCollection header, then each feature individually, then the closing bracket. FastAPI accepts StreamingResponse with a generator argument directly. Avoid materialising more than one feature at a time in the generator body.

Binary format migration trade-offs. FlatGeobuf is the right choice for web clients that need progressive HTTP range-request loading. GeoParquet suits analytical pipelines and batch exports but is not designed for direct browser consumption. Both require client-side decoders — the flatgeobuf npm package, or deck.gl loaders for GeoParquet — so factor SDK bundle size into your decision. For ZSTD compression levels inside GeoParquet containers, level 3 is the correct default for vector data in API-serving contexts: it matches Brotli’s throughput profile while giving deterministic decompression latency.


FAQ

How many decimal places should GeoJSON coordinates use for web mapping?

Five to six decimal places is the practical ceiling. Six decimal places resolves to roughly 11 cm at the equator — more than sufficient for routing, choropleth rendering, or point clustering. Most GIS export tools default to 15 decimal places, so trimming to six typically reduces coordinate character count by 40–45%.

Does Brotli compression work well on GeoJSON?

Yes. GeoJSON’s repetitive key names — "type", "coordinates", "properties" — and floating-point patterns compress well. Brotli at quality level 4–6 typically achieves 20–30% better ratios than gzip on GeoJSON responses and adds negligible CPU overhead relative to the serialization cost itself.

When should I switch from GeoJSON to FlatGeobuf for APIs?

Switch when profiling shows that client-side JSON parsing — not network transfer — is your dominant latency cost. FlatGeobuf eliminates the parse step entirely for compliant loaders and supports HTTP range requests, making it practical for web clients that need progressive loading of large feature collections.

Can I reduce GeoJSON size without changing the format?

Yes. Precision trimming, whitespace stripping, property projection, and bounding-box filtering are all format-preserving optimisations. Combined with Brotli at the HTTP layer, these measures routinely achieve 70–90% payload reduction on real-world API responses without requiring any client-side changes.


← Back to GeoJSON Overhead and Serialization Costs