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.
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:
# 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.dumpsomits trailing zeros from floats (-73.984738not-73.98473800000), shaving a further 5–10% versus stdlibjson.keep_fieldsis the server-side equivalent of a SQLSELECTprojection. Define it per endpoint: a routing API might only needidandspeed_limit; a choropleth map might needidandvalue.- The
bboxparameter provides a last-resort client-side filter. For datasets with more than ~10,000 features, push this filter into your database usingST_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:
# 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.
Related
- GeoJSON Overhead and Serialization Costs — CPU and memory cost profiles for serialization-heavy workloads
- Comparing GeoParquet vs FlatGeobuf Performance — benchmark workflow and decision matrix for binary format selection
- Understanding Parquet Columnar Storage for GIS — why columnar storage reduces analytical payload sizes
- ZSTD Compression Levels for Geospatial Data — compression level selection for GeoParquet and binary formats
- Row Group Sizing Strategies for Parquet — chunking decisions that interact with API payload size
← Back to GeoJSON Overhead and Serialization Costs