Turning objects into bytes and back. Usually the biggest CPU cost in a service, and the thing that breaks when you deploy a new version.
Prerequisites: Computer Fundamentals Time to read: ~16 minutes
Your service has a User object in memory. Another service, on another machine, in another
language, needs it. Memory layouts don’t travel — you must convert it to a byte sequence, ship it,
and reconstruct it on the other side.
Every choice you make here shows up in three places:
That third one is the one that causes outages.
{"id": 42, "name": "Bilal", "email": "b@example.com", "active": true}
The default for public APIs, and rightly so. Human-readable, debuggable with curl, supported
everywhere, no schema or code generation required.
Costs:
"email" appears 10,000 times.int64 values silently lose precision in
JavaScript (which is why every serious API returns large IDs as strings).active is a boolean. (JSON Schema exists and is
underused.)📐 A user record is ~70 bytes as JSON, ~25 bytes as protobuf. At 100,000 QPS that’s 4.5 MB/s of pure field-name overhead.
Schema-first, binary, from Google. The default for internal service-to-service communication.
syntax = "proto3";
message User {
int64 id = 1; // field NUMBERS are the wire identity, not names
string name = 2;
string email = 3;
bool active = 4;
}
You compile this into generated classes for Go, Java, Python, C++, etc. On the wire, only the field number, type, and value are sent — never the name.
Gains: 3–10× smaller, 5–10× faster to parse, statically typed, and the schema is a real contract enforced by the compiler.
Costs: unreadable on the wire (you need the schema to decode it), a build step, and coordination when schemas change.
The killer feature — safe evolution. Because fields are identified by number:
message User {
int64 id = 1;
string name = 2;
string email = 3;
bool active = 4;
string phone = 5; // NEW: old readers ignore field 5. Nothing breaks.
}
Rules that make this work:
reserved 5; the number so nobody reuses it.email in one version and phone in another
means one service will read a phone number into an email field, silently.🚨 That “never reuse a field number” rule is the most important operational fact about protobuf, and it’s a great thing to mention in an interview about API evolution.
Schema-first, binary, from the Hadoop ecosystem. Dominant in data pipelines and Kafka.
Its distinguishing idea: the schema travels with the data — either embedded in the file header (for batch files) or referenced by ID via a schema registry (for streaming).
Why this matters for data: the reader uses both the writer’s schema and its own, and resolves between them. That gives you excellent schema evolution for long-lived data — you can read a file written three years ago with today’s code.
Gains: no code generation required (dynamic typing possible), very compact, best-in-class schema evolution, first-class integration with Kafka via Confluent Schema Registry.
Costs: you need the schema to read anything (so the registry becomes critical infrastructure), and it’s less convenient for RPC than protobuf.
From Facebook. Similar to protobuf, but ships with an RPC framework and transport layer built in. Used at Facebook, Uber, Evernote. Largely superseded by gRPC + protobuf in new systems, but you’ll meet it in older codebases.
“Binary JSON” — schemaless binary formats. Smaller and faster than JSON, no schema or build step, still self-describing. A good middle ground when you want JSON’s flexibility with less overhead. MongoDB uses BSON internally; Redis clients and IoT protocols often use MessagePack or CBOR.
Java Serializable, Python pickle, Ruby Marshal, .NET BinaryFormatter.
🚨 Avoid these for anything crossing a process boundary. Three reasons:
pickle or Java-serialized data is remote code execution.
This has caused numerous major CVEs.Fine for a local cache you control end to end. Never for an API, a queue, or persisted data.
| JSON | Protobuf | Avro | MessagePack | |
|---|---|---|---|---|
| Human-readable | ✅ | ❌ | ❌ | ❌ |
| Schema required | ❌ | ✅ | ✅ | ❌ |
| Relative size | 1× | 0.2–0.4× | 0.2–0.4× | 0.6–0.8× |
| Relative parse speed | 1× | 5–10× | 5–10× | 2–3× |
| Schema evolution | Manual/convention | Excellent (field numbers) | Excellent (reader+writer schemas) | Manual |
| Code generation | No | Yes | Optional | No |
| Best for | Public APIs, config, debugging | Internal RPC (gRPC) | Data pipelines, Kafka, long-lived storage | Compact messaging without a build step |
Simple decision rules that hold up in interviews:
| Situation | Use | Why |
|---|---|---|
| Public REST API | JSON | Every client can consume it; debuggability wins |
| Internal service-to-service, high volume | Protobuf + gRPC | Size, speed, and a compiler-enforced contract |
| Kafka topics, event streams, data lake | Avro + Schema Registry | Schema evolution for data that outlives the code |
| Browser-facing | JSON | It’s what the platform speaks |
| Mobile with constrained bandwidth | Protobuf | Payload size directly costs users money and battery |
| Config files | YAML/JSON/TOML | Humans edit them |
| Caching in Redis | MessagePack or protobuf | Compact, fast; readability rarely matters |
🎙️ “I’d use JSON at the public edge for compatibility and debuggability, and protobuf over gRPC internally — at our internal call volume, the CPU spent on JSON parsing is a real cost, and the schema gives us a contract that breaks at build time instead of in production.”
Whatever format you pick, you will deploy version N and version N+1 simultaneously. Rolling deploys guarantee it. Mobile apps guarantee it for years.
Two directions, and you need both:
Rules that keep you safe:
| ✅ Safe | ❌ Breaking |
|---|---|
| Add an optional field with a default | Add a required field |
| Remove an optional field (and reserve its number/name) | Remove a field others still read |
| Add a new enum value — if readers handle unknowns | Rename a field |
| Widen a type where the format allows | Change a field’s type or number |
| Add a new endpoint / message type | Change a field’s meaning while keeping its name |
🚨 That last one is the nastiest: repurposing status from "active"/"inactive" to
"active"/"paused"/"cancelled" doesn’t break parsing — it breaks logic, silently, in every
consumer that has an if status == "inactive" branch. No schema system catches this. Only a new
field does.
🚨 New enum values are a classic production bug. Producer adds PENDING_REVIEW; a consumer’s
switch statement has no case for it and either throws or falls into a wrong default. Always design
consumers to handle unknown enum values explicitly.
A schema registry (Confluent Schema Registry for Avro/protobuf, or Buf for protobuf) makes this enforceable: it stores schema versions and rejects a producer whose new schema breaks compatibility with what consumers expect. This turns a production incident into a CI failure, which is exactly where you want it.
Compression is a separate layer, and it’s usually a clear win.
| Algorithm | Ratio | Speed | Use |
|---|---|---|---|
| gzip | Good | Moderate | The safe default; universally supported |
| brotli | Better (~15–20% over gzip) | Slower to compress | Static web assets, precompressed |
| Snappy | Modest | Very fast | Internal data, Kafka, databases |
| zstd | Excellent | Fast, tunable | Increasingly the best all-round choice |
| LZ4 | Modest | Fastest | When CPU is the constraint |
📐 The trade is lopsided in compression’s favour. Compressing 1 KB costs ~2 µs of CPU. Sending 700 fewer bytes over a link with 50–150 ms RTT and limited bandwidth saves vastly more. Enable gzip or brotli on HTTP responses by default.
When not to compress: already-compressed data (JPEG, MP4, PNG), very small payloads (under ~1 KB the overhead isn’t worth it), and situations where you’re CPU-bound and bandwidth-rich (some in-datacenter paths — use Snappy or LZ4 there rather than nothing).
| Choice | Gain | Cost |
|---|---|---|
| JSON | Universal, debuggable, no build step | 3–5× larger, 5–10× slower to parse, no enforced schema |
| Binary + schema | Small, fast, contract-enforced | Build step, opaque on the wire, registry to operate |
| Schema registry | Breaking changes caught in CI | New critical infrastructure to run |
| Compression | Big bandwidth savings | CPU cost; nothing for already-compressed data |
| Language-native | Trivial to use | Insecure, single-language, fragile versioning |
pickle/Java serialization across a trust boundary. An immediate security flag.1. Measure the difference. Take a realistic object (say 20 fields, nested). Serialize 100,000 of them as JSON and as protobuf. Compare total bytes and wall-clock time. The numbers are usually more dramatic than people expect.
2. Break compatibility on purpose. Define a protobuf message, generate code, serialize a message. Then change field 3’s number to 7, regenerate, and try to deserialize the old bytes. Watch what happens — and note that it doesn’t error, it silently produces wrong data. That’s the lesson.
3. See the wire format.
# Encode with protoc and hexdump it
echo 'id: 42 name: "Bilal"' | protoc --encode=User user.proto | xxd
Count the bytes. Compare to {"id":42,"name":"Bilal"}. Notice the field names are simply absent.
pickle data dangerous?