Tutorial
gRPC vs REST Is the Wrong First Question — Here's the One That Actually Matters
Stop conflating transport and serialization. A practical framework for choosing JSON, Protobuf, or MessagePack, and where gRPC vs REST fits.
Every “gRPC vs REST” thread eventually collapses into the same confusion: someone benchmarks JSON over HTTP against Protobuf over gRPC, declares Protobuf “faster,” and ships a migration plan that conflates two independent decisions.
Here’s the untangling: transport protocol (how bytes move between services — HTTP/1.1, HTTP/2, WebSockets, raw TCP) and serialization format (how your data structures get turned into those bytes — JSON, Protobuf, MessagePack) are separate decisions that happen to travel together by convention. REST conventionally pairs with JSON over HTTP/1.1. gRPC conventionally pairs with Protobuf over HTTP/2. But nothing stops you from running JSON over gRPC, or Protobuf over a plain REST-style HTTP API. Common serialization formats used for API payloads include JSON, XML, and Protocol Buffers, and they’re explicitly listed as one option among several — not a package deal with any particular transport.
If you only take one thing from this article: pick your transport based on the communication pattern you need (request/response, streaming, bidirectional), and pick your serialization format based on your payload size, schema stability, and tooling constraints. They’re coupled by convention and tooling convenience, not by law. The rest of this piece is the serialization decision, because that’s almost always the one with hidden traps.
The real question: what format should ride on the wire?
Serialization format is one factor in API data transmission design, sitting alongside the transport protocol (HTTP/1.1, HTTP/2, HTTP/3), security and encryption, compression, and caching strategy. Treating it in isolation is a mistake, but treating it as identical to the transport choice is a bigger one.
At its core, an encoding format defines how structured data gets represented as bytes, and every format makes trade-offs between three things: how efficiently the data can be structured, how long encoding and transfer take, and how easily the resulting payload can be parsed. JSON, Protobuf, and MessagePack sit at different points on that triangle, and the right choice depends on which corner matters most for your service.
There’s also a structural decision baked into any format you pick: whoever produces a message has to decide its shape — field names, data types, whether a value is singular or repeated — and whoever consumes it has to understand those exact same decisions to deserialize correctly. JSON lets you make that decision implicitly and loosely. Protobuf forces you to make it explicitly, in a schema, before you write a line of handler code. That difference is the whole ballgame.
JSON: self-describing, forgiving, and heavier than it looks
JSON is the format most engineers reach for by default, and for internal APIs that aren’t under serious load, that default is often fine. Its defining trait is that it’s self-describing — a JSON payload carries its own field names and structure inline, so a consumer can parse it without ever seeing a schema. That’s enormously convenient: you can curl an endpoint, read the response, and understand it immediately, no .proto file, no code generation step, no decoder ring.
That convenience is not free. Self-describing formats like JSON typically produce larger messages than schema-based binary formats, because every field name gets repeated, as text, in every single message. A payload with a field called transaction_timestamp pays for those 21 characters on every request, forever, regardless of how many times the same shape crosses the wire. JSON’s textual nature is also what makes it easy to read and debug in flight — you can log a request body and understand it at a glance — but that same textual nature increases both the volume of data transferred and the CPU cost of serializing and deserializing it compared with binary formats like Protobuf.
So: JSON characteristics in one line — human-readable, self-describing, schema-less by default, and structurally larger on the wire than binary alternatives. For low-volume internal APIs, debugging-heavy environments, or anywhere a human will regularly inspect the payload, that trade is usually worth it. For high-throughput service-to-service calls where you’re paying per byte and per millisecond at scale, it starts to hurt.
Protobuf: contract-first, compiled, and genuinely compact — if you use it correctly
Protocol Buffers flip JSON’s model entirely. You write a .proto file describing your message shape — field names, types, field numbers — compile it, and get strongly-typed client and server code generated for whatever languages you need. Nothing about the field names travels on the wire; the schema lives in the generated code on both ends, and the wire format only carries field numbers and values.
This contract-first approach is why gRPC is widely treated as the standard choice for polyglot microservices: it enforces a strict contract between services, supports streaming natively, and ships strongly-typed client libraries generated straight from the schema across languages. If you’re running Django, FastAPI, and a Node service all talking to each other, generating a typed client for each from one shared schema is a meaningful operational win — you stop hand-maintaining parallel type definitions in three languages and hoping they stay in sync.
What the wire format actually looks like
Protobuf’s compactness comes directly from its binary encoding scheme. Each field in a message is written as a tag (a combination of the field number and wire type) followed by the value, and for variable-length data like strings or nested messages, a length prefix tells the decoder exactly how many bytes to consume before the next field starts. Nested sub-messages are themselves encoded as length-delimited blobs embedded inside the parent message — recursively, as deep as your schema goes. This binary layout isn’t human-readable the way JSON is: you can’t just curl an endpoint and read the response off the screen, because there are no field names or text structure in the payload itself, only field numbers and raw bytes. That’s the trade you’re making — you give up curl-and-read debuggability in exchange for a dramatically smaller, faster-to-parse payload, because the receiving end already knows the shape from the compiled schema rather than having to infer it from field names in the payload itself.
Schema evolution is a feature, not an afterthought
Because Protobuf fields are identified by number rather than by position or name, you can add new fields to a message without breaking old consumers, and remove fields (carefully) without breaking new ones, as long as you don’t reuse a retired field number. This gives you a real backward/forward compatibility story baked into the format itself — something JSON technically allows too (just add a key) but doesn’t enforce or document anywhere; with JSON, compatibility is a convention your team has to maintain by discipline, not something the format checks for you. For service-to-service contracts that multiple teams depend on independently, that difference in enforced versus informal compatibility is one of the strongest arguments for Protobuf that has nothing to do with payload size.
The trap: dynamic Protobuf can be bigger than the JSON you were trying to replace
Here’s the part that catches teams off guard, and it’s worth slowing down for. Protobuf ships a set of Well-Known Types, including google.protobuf.Value and google.protobuf.Struct, which act as dynamic, schema-less wrappers — they can represent an arbitrary JSON-like value (null, number, string, boolean, nested object, or list) at runtime, for cases where you don’t know the shape at compile time. It’s tempting to reach for these when part of your payload is genuinely dynamic, or when a team is “migrating to Protobuf” but doesn’t want to commit to strict schemas yet.
Don’t. Most of Protobuf’s speed and size advantage comes specifically from ahead-of-time, statically compiled schema knowledge — the generated code already knows every field’s number, type, and position before a single byte is parsed. The moment you wrap your payload in Value or Struct instead of real typed fields, you throw that knowledge away, and most of the optimizations that make Protobuf fast and compact go with it.
It gets worse at the wire level. Protobuf has no native map primitive — a map field (which is what Struct uses internally to hold its dynamic keys) is actually encoded as a repeated list of auto-generated key-value sub-messages, and each of those sub-messages carries its own tag and length-prefix overhead. For small, dynamic payloads, that per-entry structural metadata can outweigh the size of the actual data you’re trying to send — meaning a “Protobuf” payload built on Value/Struct can end up larger than the equivalent plain JSON you were trying to replace. This is the anti-pattern to watch for: teams “upgrade” from JSON to Protobuf, reach for dynamic types to avoid committing to a schema, and quietly lose both the size and speed benefits they adopted Protobuf for in the first place. If your data is genuinely dynamic and schema-less, that’s a signal to use JSON (or MessagePack) directly, not to simulate schema-lessness inside Protobuf.
MessagePack: binary JSON, no schema required
MessagePack occupies the middle ground: it’s a binary format, so it’s more compact than text-based JSON, but it keeps JSON’s schema-less data model — objects, arrays, strings, numbers, booleans, null — rather than requiring a compiled contract. You can usually serialize the same in-memory object you’d otherwise hand to JSON.stringify or Python’s json.dumps, and get back a smaller binary payload with no .proto file, no code generation step, and no build pipeline changes.
That positions MessagePack as a reasonable drop-in when you want JSON’s flexibility and low-ceremony adoption but need meaningfully smaller payloads than text JSON provides — internal caches, message queue payloads, or service calls where you want size savings without the schema commitment of Protobuf. A 2024 academic study by Maltsev and Muliarevych specifically investigated alternative serialization formats — including Avro and MessagePack — to evaluate how much they could reduce data size relative to JSON in distributed systems communication, which reflects a broader pattern of looking past JSON for size-sensitive paths without necessarily adopting a full schema-compiled approach like Protobuf. I’d flag, honestly, that neither that study’s concrete numbers nor a detailed MessagePack feature comparison against CBOR or Avro are things I can responsibly hand you figures for here — if you need exact byte-for-byte savings for your own payload shapes, that’s worth measuring directly against your actual data rather than trusting a number pulled from someone else’s schema.
Performance reality: what’s actually been measured
It’s tempting to want a clean table of “JSON: X ms, Protobuf: Y ms, MessagePack: Z ms” — I don’t have verified figures at that granularity, and I’d rather tell you that plainly than invent numbers that look authoritative and aren’t. What does exist is a comparative study that implemented REST (HTTP/1.1 + JSON) and gRPC (HTTP/2 + Protobuf) identically in ASP.NET Core 8, then measured average latency, P95 latency, successful operations per second, total data volume transmitted, and CPU/RAM utilization across both. That’s the right methodology — same application logic, same infrastructure, only the transport-and-format pairing changed — and it’s a useful template if you want to validate the choice for your own services rather than trust a vendor benchmark.
One caution from the RPC-framework side worth carrying into your own testing: in at least one framework comparison, gRPC needed additional tuning to handle large payloads effectively during benchmarking — it wasn’t a zero-configuration win across all payload sizes out of the box. If your service occasionally ships large blobs (bulk exports, image metadata batches, log bundles), don’t assume gRPC’s defaults will handle that gracefully without tuning; test it.
Transport, briefly: HTTP/1.1, HTTP/2, and why gRPC picked HTTP/2
Since transport and format travel together in practice, it’s worth being precise about why gRPC chose HTTP/2 specifically. HTTP/2 supports binary framing and multiplexing — multiple request/response streams sharing a single TCP connection — and that multiplexing is one of the direct reasons gRPC uses HTTP/2 as its transport layer rather than HTTP/1.1. Practically, that means a gRPC client can have many concurrent calls in flight over one connection without the head-of-line blocking and connection-churn overhead that plain HTTP/1.1 REST APIs deal with under high concurrency. That’s a transport-layer win, independent of whatever format rides inside it — it’s one more reason gRPC and Protobuf are bundled by convention, but it’s not an argument that Protobuf itself requires HTTP/2, or that REST can’t use HTTP/2 (it can; many REST APIs already do).
For service-to-service calls specifically — as opposed to browser-facing APIs — HTTP/2’s multiplexing and gRPC’s native streaming support matter most when you have high call volume between a small number of known services, or when you need server-streaming or bidirectional-streaming patterns (think: a FastAPI service pushing incremental updates to a worker, rather than one-shot request/response). Plain REST over HTTP/1.1 or HTTP/2 remains perfectly reasonable for lower-volume internal calls, public-facing APIs consumed by third parties, or anywhere debuggability and broad client compatibility outweigh raw throughput. WebSockets are a different tool again — useful for persistent, bidirectional browser connections — but that’s outside the service-to-service scope here.
A decision framework you can actually apply
Rather than a universal verdict, here’s how to reason through it for a specific service pair:
- If payloads are small, call volume is low, and multiple teams or external consumers need to read/debug traffic easily — stay with JSON. The self-describing nature and human readability outweigh the size cost at low volume.
- If you’re building internal microservices with a shared schema, strict typing across languages, and want generated clients — adopt Protobuf via gRPC, and keep every field explicitly typed in the schema; never reach for
Value/Structas a shortcut around designing the schema properly. - If you want JSON’s flexibility and schema-less convenience but need smaller payloads — for a cache layer, a queue, or a quick internal hop — MessagePack is a reasonable middle ground, with the explicit caveat that detailed performance figures should come from testing your own payloads, not borrowed numbers.
- If you’re unsure whether schema evolution will matter — it almost always does once more than one team depends on the contract, which tips the scale toward Protobuf’s enforced field-numbering discipline over JSON’s informal conventions.
- If large, infrequent payloads are part of your traffic mix — test gRPC/Protobuf under that specific load before committing; don’t assume default tuning handles it.
Bringing it back together
“gRPC vs REST” was never really the question — it’s shorthand for a transport decision (HTTP/1.1 vs HTTP/2, request/response vs streaming) bundled with a serialization decision (text vs binary, schema-less vs schema-compiled) that happen to ship together by convention. Separate them, and the serialization choice resolves on its own merits: JSON for readability and low ceremony at the cost of size, Protobuf for compactness and enforced contracts at the cost of a build step and a real risk of misuse via dynamic types, MessagePack as a schema-less middle ground when you want binary efficiency without committing to a compiled contract. Pick the format that matches your actual constraints — payload size, schema stability, team polyglot-ness, debugging needs — and then pick the transport that matches your traffic pattern. You’ll end up with a better architecture than anyone who answered “gRPC vs REST” as if it were one question.
FAQ
Can I use Protobuf with a REST-style API instead of gRPC? Yes — nothing about Protobuf requires gRPC or HTTP/2. You can serialize a Protobuf message and send it over a plain HTTP/1.1 REST endpoint; you lose gRPC’s native streaming and generated-service-stub tooling, but you keep Protobuf’s compact wire format and schema enforcement.
Is MessagePack a safe drop-in replacement for JSON? In most cases, yes, for the data model itself — MessagePack maps cleanly onto the same objects, arrays, and primitives JSON uses. The actual size and speed improvement for your specific payloads is something to measure directly, since no verified benchmark here gives exact figures.
Why does “upgrading” to Protobuf sometimes make payloads bigger, not smaller?
This happens when teams use Protobuf’s dynamic Well-Known Types (Value, Struct) instead of defining explicit typed fields. Those dynamic wrappers discard the compile-time schema knowledge that normally makes Protobuf compact, and their internal map encoding adds per-entry tag and length overhead that can exceed the size of plain JSON for small payloads. The fix is to define real schemas for your data, not to simulate JSON’s flexibility inside Protobuf.
Does choosing gRPC force me to use Protobuf, or choosing REST force me to use JSON? No — that’s the core confusion this article addresses. gRPC is commonly paired with Protobuf and REST is commonly paired with JSON, but the transport (gRPC/HTTP-2 vs REST/HTTP-1.1) and the serialization format are independent decisions that convention bundles together, not a hard technical requirement.
How do I know if schema evolution matters for my service? If more than one team or one deployable consumes the same contract independently — which is most real microservice setups — schema evolution matters, because you’ll eventually need to add or change fields without breaking consumers that haven’t redeployed yet. Protobuf’s field-numbering scheme handles this explicitly; JSON can technically evolve too, but only if your team enforces compatibility conventions manually.