Protocol Buffers vs GeoJSON for High-Frequency Spatial Events
For a low-frequency administrative webhook, send RFC 7946 GeoJSON; for a high-frequency sensor firehose above roughly 10,000 events per second, send Protocol Buffers with packed coordinate fields and keep GeoJSON as your canonical internal form. This decision guide sits under GeoJSON to Protobuf Mapping, part of Spatial Payload Routing & Parsing.
When to use this pattern
The choice is a throughput and trust decision, not a matter of taste:
- Reach for GeoJSON when volume is modest (a few events per second), when third parties or humans read the payload, or when you want RFC 7946 validation and off-the-shelf tooling to work without a
.protoschema. Feature-edit webhooks, boundary-change notifications, and admin callbacks all fit here. - Reach for Protocol Buffers when you push a firehose of vehicle pings, IoT telemetry, or tracking updates at 10,000+ events per second, where every byte and every CPU cycle spent in the JSON tokenizer is multiplied across the fleet and directly drives your broker and compute bill.
- This is not the right axis to optimize when your bottleneck is a slow downstream database or a blocking geometry validator. Compressing the wire format will not help if a single
ST_IsValidcall dominates each event; profile first.
Why the wire format matters at scale
GeoJSON is text. Every coordinate is written as ASCII digits, wrapped in a "coordinates" key, and surrounded by quotes, brackets, and whitespace. A single [-73.985428, 40.748817] position costs over twenty bytes and forces the receiver to run a JSON tokenizer and parse each number from a string. Protocol Buffers encodes the same position as two 8-byte IEEE 754 doubles in a packed field with a one-byte tag: no key names, no quotes, no decimal-to-ASCII expansion, and no tokenizer. At 10,000 events per second that structural difference compounds into megabytes per second of network and a measurable share of a CPU core.
The diagram shows the split most production systems converge on: a binary format on the hot path, GeoJSON everywhere a human or a generic tool touches the data, and a single canonical decode step that unifies them.
Head-to-head comparison
The multipliers below are representative for coordinate-dense geometries (lines and polygons with tens to hundreds of vertices) and are illustrative starting points; your own geometries and CPUs will vary, so treat them as a hypothesis to measure, not a guarantee.
| Dimension | GeoJSON (RFC 7946) | Protocol Buffers (packed / WKB) |
|---|---|---|
| Wire size (uncompressed) | 1.0x baseline | ~0.25x-0.35x (3-4x smaller) |
| Wire size (gzipped) | ~0.35x baseline | ~0.25x-0.30x |
| Parse CPU at 10k+/s | 1.0x baseline | ~0.15x-0.30x (3-6x cheaper) |
| Serialize CPU | 1.0x baseline | ~0.3x-0.5x |
| Schema enforcement | none on the wire; validate against RFC 7946 | strong, compiled from .proto |
| Schema evolution | ad hoc, additive by convention | field numbers, reserved tags, backward-compatible |
| Human debuggability | excellent (plain text) | poor (needs the schema to decode) |
| Tool interop | universal (browsers, PostGIS, QGIS) | requires generated stubs on both ends |
| Coordinate fidelity | full double precision as text | full double, or lossy if you pick float |
The pattern is consistent: Protobuf wins decisively on size and CPU, GeoJSON wins decisively on debuggability, validation-by-standard, and interop. High-frequency machine-to-machine traffic values the former; low-frequency human-facing traffic values the latter.
Complete runnable implementation
The script below defines one geometry as GeoJSON and as an equivalent protobuf message, then measures serialized sizes. The .proto is shown first so you can regenerate the stubs; the runnable Python uses a tiny hand-written encoder so it executes without the protoc toolchain while matching the exact wire layout protoc would emit for packed double fields. For the WKB-inside-Protobuf alternative, see Converting WKT to Protobuf for Low-Latency Routing.
// spatial_event.proto — compile with: protoc --python_out=. spatial_event.proto
syntax = "proto3";
package spatial;
// A LineString as flat, packed coordinate pairs in EPSG:4326 (WGS84).
// Storing lon/lat as parallel packed doubles keeps the payload dense.
message LineString {
repeated double lon = 1 [packed = true]; // longitude first, per RFC 7946
repeated double lat = 2 [packed = true]; // latitude second
}
message SpatialEvent {
string event_id = 1;
int64 emitted_at = 2; // unix millis
LineString geometry = 3;
}
"""Compare serialized size of one geometry as GeoJSON vs Protobuf.
Runs with only the standard library. The _pack_geometry function emits the
exact bytes proto3 would produce for two packed `double` fields (tag,
length-prefix, then little-endian float64 values), so the size comparison is
faithful without needing the protoc-generated class installed.
"""
import json
import struct
# One geometry: a short LineString in EPSG:4326 (WGS84), lon/lat order.
COORDS = [
[-73.985428, 40.748817],
[-73.984100, 40.750500],
[-73.982900, 40.752100],
[-73.981700, 40.753800],
]
# --- GeoJSON form (RFC 7946) ---
geojson = {
"type": "Feature",
"geometry": {"type": "LineString", "coordinates": COORDS},
"properties": {"event_id": "evt-42"},
}
geojson_bytes = json.dumps(geojson, separators=(",", ":")).encode("utf-8")
# --- Protobuf form: two packed double fields (field 1 = lon, field 2 = lat) ---
def _packed_double_field(field_number: int, values: list[float]) -> bytes:
payload = b"".join(struct.pack("<d", v) for v in values) # little-endian float64
tag = (field_number << 3) | 2 # wire type 2 = length-delimited
return bytes([tag]) + _varint(len(payload)) + payload
def _varint(n: int) -> bytes:
out = bytearray()
while True:
b = n & 0x7F
n >>= 7
out.append(b | (0x80 if n else 0))
if not n:
return bytes(out)
def encode_protobuf(coords: list[list[float]]) -> bytes:
lons = [c[0] for c in coords]
lats = [c[1] for c in coords]
return _packed_double_field(1, lons) + _packed_double_field(2, lats)
protobuf_bytes = encode_protobuf(COORDS)
# --- Decode the protobuf back to coordinates for the round trip ---
def decode_protobuf(buf: bytes) -> list[list[float]]:
fields: dict[int, list[float]] = {}
i = 0
while i < len(buf):
tag = buf[i]; i += 1
field_number = tag >> 3
length, i = _read_varint(buf, i)
block = buf[i:i + length]; i += length
fields[field_number] = [
struct.unpack("<d", block[j:j + 8])[0] for j in range(0, len(block), 8)
]
return [[lon, lat] for lon, lat in zip(fields[1], fields[2])]
def _read_varint(buf: bytes, i: int) -> tuple[int, int]:
result = shift = 0
while True:
b = buf[i]; i += 1
result |= (b & 0x7F) << shift
if not (b & 0x80):
return result, i
shift += 7
if __name__ == "__main__":
print(f"GeoJSON bytes : {len(geojson_bytes)}")
print(f"Protobuf bytes: {len(protobuf_bytes)}")
print(f"ratio : {len(protobuf_bytes) / len(geojson_bytes):.2f}x")
# Confirm the binary form is a faithful, lossless carrier of the geometry.
assert decode_protobuf(protobuf_bytes) == COORDS
print("round trip OK")
Running it prints the two byte counts and their ratio. For this four-vertex line the protobuf form is well under half the GeoJSON size; add vertices and the gap widens because the per-coordinate overhead in text grows while the binary form stays at a flat 8 bytes per ordinate.
Parameter reference
The settings below change correctness or the size/CPU trade-off, not just style. Coordinate constraints assume RFC 7946, EPSG:4326 (WGS84), longitude first.
| Parameter | Type | Spatial constraint / effect | Default |
|---|---|---|---|
| coordinate scalar | double vs float |
double preserves full GeoJSON precision; float halves size but truncates to ~7 significant digits, shifting positions by meters |
double |
packed = true |
proto3 field option | Packs a repeated numeric field into one length-delimited block; without it each value carries its own tag and inflates size | packed for scalars in proto3 |
| coordinate order | field convention | Must be lon then lat to match RFC 7946; swapping silently mirrors geometry across the diagonal | none enforced |
| field numbers | int tags | Immutable once shipped; reuse of a retired number corrupts old readers, so reserved retired tags |
none |
emitted_at |
int64 millis |
Cheaper and unambiguous versus an ISO timestamp string; avoids timezone parsing on the hot path | none |
| gzip on GeoJSON | transport flag | Recovers much of the size gap for text but adds compression CPU per event | off |
| canonical decode target | representation | Decode every wire format to one GeoJSON shape before hashing or storage | none |
Gotchas and spatial edge cases
- Float precision loss in packed fields. Choosing
float(32-bit) instead ofdoubleto save bytes truncates coordinates to roughly seven significant digits. At mid latitudes that is meters of drift, enough to move a point across a parcel boundary. Usedoublefor any coordinate you will store or compare, and reservefloatfor disposable low-accuracy telemetry only. - Coordinate order (lon/lat) is unlabeled in binary. GeoJSON text at least has a spec you can cite; a packed double field is just numbers. If a producer packs lat first, every geometry is mirrored across the diagonal and nothing errors. Pin the order in the
.protocomment and assert it in a round-trip test. - Losing RFC 7946 validation. Protobuf enforces field types, not spatial validity. It will happily carry an unclosed polygon ring, a self-intersecting boundary, or a longitude of 500. Keep the standards-based checks from Parsing GeoJSON Webhooks with FastAPI and Pydantic on the canonical form after you decode.
- Schema migration. Never renumber or reuse a field tag once a message is in production; readers key on the number, not the name. Add new fields with fresh numbers and mark retired ones
reservedso an old consumer skips unknown fields instead of misreading them. - Mixing formats breaks idempotency hashing. If you hash raw wire bytes, the same event arriving once as GeoJSON and once as Protobuf yields two different keys and slips past deduplication. Hash a canonical decoded representation instead, per GeoJSON to Protobuf Mapping, so the key is format-independent.
- Elevation and the optional third ordinate. RFC 7946 allows a
[lon, lat, elevation]position. A rigid two-field packed schema silently drops the elevation. Model the optional third ordinate explicitly if any producer emits 3D positions.
Verification
The round-trip assertion is the contract that lets you trust a binary wire format: encode, decode, and confirm the geometry is unchanged. Run the following with pytest; it reuses the encoder and decoder from the implementation above.
import struct
from spatial_size import COORDS, encode_protobuf, decode_protobuf
def test_protobuf_round_trip_is_lossless():
# Encoding then decoding must reproduce every ordinate exactly with double.
assert decode_protobuf(encode_protobuf(COORDS)) == COORDS
def test_float_would_lose_precision():
# Demonstrates WHY the schema uses double: a 32-bit round trip drifts.
lon = COORDS[0][0]
as_float = struct.unpack("<f", struct.pack("<f", lon))[0] # 32-bit round trip
assert as_float != lon # float32 cannot hold the full coordinate
assert abs(as_float - lon) < 1e-4 # but the error is small and silent
def test_protobuf_is_smaller_than_geojson():
import json
gj = json.dumps({"type": "LineString", "coordinates": COORDS}).encode()
assert len(encode_protobuf(COORDS)) < len(gj)
A passing run proves three things at once: the binary form is a lossless carrier of the geometry under double, float would have introduced silent drift, and the protobuf payload is genuinely smaller for the same coordinates. That is the whole trade-off in three assertions.
FAQ
Is Protocol Buffers always smaller than GeoJSON?
For coordinate-heavy geometries Protobuf with packed double fields is typically three to five times smaller than the equivalent RFC 7946 GeoJSON, because it drops key names, quotes, and the decimal-to-ASCII expansion of every number. For tiny payloads dominated by a single point the gap narrows, and gzip over GeoJSON closes much of it, so measure your own geometries before assuming a fixed ratio.
Why keep GeoJSON as the canonical internal form if Protobuf is on the wire?
GeoJSON is human-debuggable, tool-interoperable, and validatable against RFC 7946, which makes it the right shape for storage, logs, and idempotency hashing. Protobuf is a transport optimization. Decode to a canonical GeoJSON representation as soon as a message lands so every downstream stage sees one stable structure regardless of which format arrived on the wire.
Does switching wire formats break idempotency hashing?
Yes, if you hash the raw bytes. GeoJSON bytes and protobuf bytes for the same geometry differ completely, so the same event arriving in two formats produces two different keys and defeats deduplication. Hash a canonical decoded representation, not the wire bytes, so the key is format-independent.
Related
- GeoJSON to Protobuf Mapping — the mapping layer that turns validated GeoJSON into the binary messages this guide benchmarks.
- Parsing GeoJSON Webhooks with FastAPI and Pydantic — enforce RFC 7946 validity on the canonical form before or after the wire hop.
- Converting WKT to Protobuf for Low-Latency Routing — the WKB-inside-Protobuf alternative to packed coordinate fields.