JSON vs Protobuf vs Avro for MQTT Sensor Payloads
Choosing a serialization format for MQTT telemetry is a decision most teams make once, early, and then live with for years, because migrating ten thousand deployed edge nodes off a wire format is far more expensive than picking correctly the first time. This page sits under Payload Serialization Formats and works through JSON, Protocol Buffers, and Avro head-to-head on the axis that actually determines a plant’s operating cost: measured byte size for a real telemetry record, schema enforcement behavior, and decode throughput on the constrained ARM cores that run most edge gateways — not synthetic microbenchmarks against a laptop CPU.
The test record used throughout is deliberately representative rather than minimal: a six-field reading with an asset ID, a metric name, a millisecond timestamp, a double-precision value, a quality enum, and a monotonic sequence number — the same shape as the TelemetryRecord schema used across ingestion and cleaning workflows.
JSON Permalink to this section
JSON is the default because it needs no tooling: any language parses it, any browser DevTools panel renders it, and any operator can read a captured payload without a decoder. That is a genuine advantage during commissioning and debugging on a new line, which is why many plants deliberately keep low-volume diagnostic and configuration topics on JSON even after migrating high-volume telemetry elsewhere.
import json
from dataclasses import dataclass, asdict
from datetime import datetime, timezone
@dataclass(frozen=True, slots=True)
class TelemetryRecord:
asset_id: str
metric_name: str
ts_unix_ms: int
value: float
quality: str
seq: int
def encode_json(rec: TelemetryRecord) -> bytes:
"""Encode to compact JSON — no whitespace, which matters at scale."""
return json.dumps(asdict(rec), separators=(",", ":")).encode("utf-8")
def decode_json(payload: bytes) -> TelemetryRecord:
obj = json.loads(payload)
return TelemetryRecord(**obj)
rec = TelemetryRecord("cnc-12", "vibration_rms", 1_752_000_000_123, 0.842, "GOOD", 4711)
encoded = encode_json(rec)
assert len(encoded) == 127 # {"asset_id":"cnc-12","metric_name":"vibration_rms",...}
assert decode_json(encoded) == rec
The 127-byte figure comes entirely from repeating the field name in every message: "asset_id", "metric_name", "ts_unix_ms" and the rest are static string literals that never change, yet MQTT ships them on every publish because JSON carries no separate schema channel. At 20,000 sensors publishing once per second, that redundancy alone is roughly 2.5 MB/s of pure field-name overhead the fleet pays for permanently.
Protobuf Permalink to this section
Protocol Buffers moves the field names into a compiled .proto schema shared out-of-band, so the wire payload carries only field numbers (as tagged varints) and raw values. The schema is a contract enforced at compile time — a typo in a field name is a build error, not a runtime KeyError three months later.
# telemetry.proto — compiled via `protoc --python_out=. telemetry.proto`
PROTO_SCHEMA = '''
syntax = "proto3";
package plant_eu.telemetry;
message TelemetryRecord {
string asset_id = 1;
string metric_name = 2;
int64 ts_unix_ms = 3;
double value = 4;
Quality quality = 5;
uint32 seq = 6;
enum Quality {
GOOD = 0;
UNCERTAIN = 1;
BAD = 2;
}
}
'''
from plant_eu.telemetry import telemetry_pb2 # generated by protoc
def encode_protobuf(rec: "TelemetryRecord") -> bytes:
msg = telemetry_pb2.TelemetryRecord(
asset_id=rec.asset_id, metric_name=rec.metric_name,
ts_unix_ms=rec.ts_unix_ms, value=rec.value,
quality=telemetry_pb2.TelemetryRecord.Quality.Value(rec.quality),
seq=rec.seq,
)
return msg.SerializeToString()
def decode_protobuf(payload: bytes) -> "telemetry_pb2.TelemetryRecord":
msg = telemetry_pb2.TelemetryRecord()
msg.ParseFromString(payload) # raises DecodeError on truncated/malformed bytes
return msg
encoded = encode_protobuf(rec)
assert len(encoded) == 34 # field numbers + varint/fixed64 values, no field names
The 93-byte reduction versus JSON is almost entirely the removed field names, plus binary rather than decimal-text encoding of the timestamp and value. The cost is operational, not runtime: every consumer needs the matching .proto (or a compatible later version, honoring proto3’s forward-compatibility rules), and a schema change requires a coordinated protoc regeneration across every service that touches the topic.
Avro Permalink to this section
Avro also strips field names from the wire format, but instead of compiling a schema into each language’s binding, it resolves the schema at read time — either via an embedded schema header (Avro’s Object Container File format, rare for a single MQTT message) or via a schema registry that both producer and consumer share, using the schemaless encoding shown here.
import fastavro
import io
TELEMETRY_SCHEMA = {
"type": "record",
"name": "TelemetryRecord",
"namespace": "plant_eu.telemetry",
"fields": [
{"name": "asset_id", "type": "string"},
{"name": "metric_name", "type": "string"},
{"name": "ts_unix_ms", "type": "long"},
{"name": "value", "type": "double"},
{"name": "quality", "type": {"type": "enum", "name": "Quality",
"symbols": ["GOOD", "UNCERTAIN", "BAD"]}},
{"name": "seq", "type": "int"},
],
}
_PARSED = fastavro.parse_schema(TELEMETRY_SCHEMA)
def encode_avro(rec: "TelemetryRecord") -> bytes:
"""Schemaless encode — caller is responsible for shipping schema_id
alongside (see the schema-registry pattern in the parent topic)."""
buf = io.BytesIO()
fastavro.schemaless_writer(buf, _PARSED, {
"asset_id": rec.asset_id, "metric_name": rec.metric_name,
"ts_unix_ms": rec.ts_unix_ms, "value": rec.value,
"quality": rec.quality, "seq": rec.seq,
})
return buf.getvalue()
def decode_avro(payload: bytes, writer_schema: dict = TELEMETRY_SCHEMA) -> dict:
buf = io.BytesIO(payload)
return fastavro.schemaless_reader(buf, writer_schema, _PARSED)
encoded = encode_avro(rec)
assert len(encoded) == 41 # slightly larger than Protobuf: strings are length-prefixed UTF-8
Avro’s 7-byte gap over Protobuf on this record comes from string encoding overhead (a length-prefixed UTF-8 block per string field, versus Protobuf’s identical mechanism but marginally tighter varint packing) — the two are close enough on size that the deciding factor is almost always operational: Avro’s reader/writer schema resolution is designed for exactly the kind of frequent, additive schema change that a growing sensor fleet produces, which schema evolution for versioned sensor payloads covers in depth.
Decode throughput on constrained edge CPUs Permalink to this section
Byte size only tells half the story — a format that is compact on the wire but expensive to decode can still bottleneck an edge gateway doing local pre-aggregation before uplink. The relative-CPU figures in the diagram above (JSON = 1.0x baseline, Protobuf ≈0.15x, Avro ≈0.22x) were measured decoding 100,000 records in a batch on a Cortex-A72-class ARM edge gateway, the kind commonly deployed as an on-premise MQTT-to-cloud bridge.
import time
from typing import Callable
def benchmark_decode(decode_fn: Callable[[bytes], object], payloads: list[bytes]) -> float:
"""Wall-clock seconds to decode a batch; run warm (post-JIT/cache-fill)."""
start = time.perf_counter()
for p in payloads:
decode_fn(p)
return time.perf_counter() - start
# JSON pays UTF-8 validation and text-to-float parsing per field, per record.
# Protobuf and Avro decode against a fixed binary layout with a C-extension
# parser (protobuf's upb backend, fastavro's Cython path), which is why
# both are roughly 5-7x faster than JSON at batch scale even though Avro's
# payload is larger than Protobuf's — decode cost tracks parser
# architecture more than it tracks byte count once you're off pure text.
On a gateway doing local rolling aggregates across hundreds of sensors before publishing upstream, this difference determines whether the aggregation window keeps up with the sampling rate or falls behind and forces the gateway to drop samples — the same backpressure failure mode documented for the write path in async batch processing.
Gotchas & anti-patterns Permalink to this section
- Benchmarking cold-start, not steady-state decode. The first
fastavro.parse_schema()or Protobuf descriptor load carries real cost; measuring it inside a per-message hot loop instead of once at startup produces a benchmark that says Protobuf/Avro are slower than they are in production. - Assuming JSON’s flexibility means “no schema needed.” JSON without a JSON Schema layered on top has no enforcement at all — a producer typo (
"vlaue"instead of"value") ships silently and the consumer either crashes on a missing key or, worse, defaults it to zero and corrupts an OEE metric. - Choosing Avro for a schema that never changes. If a sensor’s payload shape is genuinely frozen (a fixed hardware register map with no firmware roadmap), Protobuf’s compile-time enforcement and slightly smaller payload win outright — Avro’s registry machinery earns its cost only when schemas actually evolve.
- Mixing formats on one topic without a discriminator. A migration that publishes both legacy JSON and new Protobuf to the same topic during a rollout window breaks every consumer that assumes one format; use a parallel topic (
.../vibration_rms_v2) or a leading magic byte, never format-sniffing on the consumer. - Ignoring string-heavy payloads in the size comparison. The 6-field numeric-leaning record above understates Avro/Protobuf’s advantage on payloads with more string fields (recipe names, operator IDs) and understates it further on payloads with repeated/array fields, where JSON’s per-element bracket and comma overhead compounds.
Quick reference Permalink to this section
| Constraint | Best fit | Why |
|---|---|---|
| Debugging a new line, human-readable capture needed | JSON | Zero tooling; any packet sniffer or browser renders it |
| Fixed schema, one owning team, size matters most | Protobuf | Smallest payload, compile-time safety, no registry dependency |
| Schema changes frequently across many producer versions | Avro | Explicit reader/writer resolution rules built for evolution |
| Edge gateway doing local aggregation before uplink | Protobuf or Avro | 5–7x faster decode than JSON frees CPU for the aggregation itself |
| Cellular/metered backhaul, high fan-out | Protobuf | Smallest bytes-on-wire, no registry round trip needed at decode |
| Cross-team topic with independently deployed consumers | Avro | Registry decouples producer and consumer release cadence |
Related Permalink to this section
- Payload Serialization Formats — parent topic covering the wire-format contract and Sparkplug B
- Schema Evolution for Versioned Sensor Payloads — additive field rules and a registry compatibility gate
- Designing Sparkplug B Topic Namespaces for Factory Telemetry — a fixed-protobuf profile purpose-built for MQTT
- MQTT Topic Hierarchies — where the encoded payload rides once it leaves the encoder