Protobuf vs JSON: When the Performance Gap Actually Matters
"Protobuf beats JSON on speed and size but at most startup scales, serialization isn't your bottleneck. Here's when the switch is actually worth it."
S
omeone on your team has a theory. Your services are slow, the payloads are fat, and the fix they're convinced is Protobuf. Switch from JSON, go binary, cut the overhead. Problem solved.They're not wrong about Protobuf being faster. They might be completely wrong about why your services are slow.
This is the conversation I keep having with engineering teams right before they spend two weeks wiring up .proto files, fixing a codegen pipeline, and watching their P99 latency stay exactly where it was. The switch didn't help because the serialization wasn't the problem. The database was. Or the missing index. Or the N+1 they'd been ignoring for six months.
Protobuf vs JSON performance in Go is a real question with a real answer. But the answer is conditional. Here's the honest version.
What Protobuf actually does differently
JSON is text. You send {"user_id":12345,"name":"Budi","active":true} down the wire. Any language reads it. Any developer can eyeball it. curl can print it. The format carries its own documentation.
Protobuf is binary. The same data becomes roughly 12-16 bytes of packed fields no keys, no quotes, no field names, just values, typed and compressed. To make sense of those bytes, both sides need the .proto schema. Without it, the payload is noise.
JSON trades payload size for readability and universal compatibility. Protobuf trades both of those for speed and density. That's the deal.
The numbers hold up. A typical API response with nested objects and arrays will be 40-60% smaller in Protobuf. Serialization in Go runs roughly 5-8x faster. For a struct that takes 2.5μs to encode with encoding/json, Protobuf's generated code does it in around 400ns.
The speed gap isn't magic. encoding/json uses reflection it inspects your struct's field types at runtime, allocates intermediate representations, and produces a string one field at a time. google.golang.org/protobuf's generated code skips all of that. Type-aware encode and decode functions written at compile time. The difference is structural, not incidental.
Those numbers look impressive until you ask the question most teams skip.
The math that changes the answer
Take a typical Go service handling a user-facing request. A call comes in, hits a handler, queries the database, maybe talks to one upstream service, marshals a response, and sends it.
Here's roughly where time goes:
- DB query: 10–80ms
- Upstream service call: 5–50ms
- Business logic: 0.1–2ms
- JSON serialization: 0.002–0.5ms
Serialization is noise. If you cut it by 10x, you've saved 0.45ms on a 50ms request. That's a 0.9% improvement. No user feels it. No on-call alert moves.
At 1,000 requests per second, the CPU time spent on JSON is real, but it's nowhere near your bottleneck. At 50,000 requests per second, it starts mattering. At 200,000 requests per second on payload-heavy internal traffic, it matters a lot.
The "Protobuf vs JSON" question is really two questions colliding. Which format is faster in isolation? And does that difference have any practical effect on your specific service at your specific scale? Most teams answer the first and skip the second.
When the switch is actually worth it
There are situations where Protobuf earns its operational complexity. They're more specific than the generic advice usually admits.
The clearest case is internal high-frequency service calls. If service A calls service B 50,000 times per second, the wire savings compound fast. Each call is 40% smaller, CPU usage for serialization drops by 80%, you're running fewer cores and paying less for them. This is the workload gRPC was designed around. Gojek's driver-location pipeline processes millions of position updates per minute at that call volume, binary serialization isn't an optimization. It's a budget line.
Mobile-first markets make the bandwidth argument differently. Indonesia runs on mobile, and mobile data has a real cost. A response that's 3KB in Protobuf versus 7KB in JSON isn't just faster to transfer it's cheaper for users on limited plans. The savings per request look small until you multiply them across 100 million daily product views. Tokopedia's mobile team treats payload size as a user experience metric, not just infrastructure hygiene.
Then there's time-series and telemetry data. High-cardinality metric streams thousands of labeled measurements per second are dense, repetitive, and structurally well-suited for binary encoding. VictoriaMetrics uses Protobuf internally for exactly this workload. When your data is numerical, typed, and arriving at volume, Protobuf's density advantage is near its theoretical maximum.
And if your services already use gRPC, Protobuf isn't something you add. It's the protocol. You're getting the benefits automatically.
The thing nobody tells you about Go's JSON performance
Before committing to Protobuf's schema management overhead, there's a move most engineering blogs skip.
encoding/json isn't your only JSON option in Go. It's the default, which is different.
github.com/goccy/go-json consistently benchmarks 2-4x faster than the standard library. It generates type-aware code paths at compile time where possible, instead of reflecting at runtime. It's a drop-in replacement swap one import, run your tests, ship it. No .proto files. No codegen step. No schema coordination between services.
github.com/json-iterator/go takes a similar approach and is widely used in production Go services across the ecosystem.
Neither closes the full gap to Protobuf. But if serialization is showing up in your profiler and you're not ready for the operational cost of binary schemas, this is the 10-minute change that cuts your JSON overhead by 60%. Try it first.
The mistake that looks like engineering
The mistake isn't switching to Protobuf. The mistake is switching to Protobuf because your service is slow.
Serialization is visible. You can benchmark it in isolation and get a dramatic before/after number. A 5x speedup is satisfying to show in a pull request. The problem is that the service doesn't care. The database is still taking 160ms. The serialization was 4ms.
I've watched teams spend two weeks wiring up gRPC, managing protoc plugins, sorting out backwards compatibility rules for schema evolution, writing migration guides for downstream consumers and then profiling the result to find their P99 improved by 3ms. Their connection pool was misconfigured. Ten max connections to Postgres on a service handling 2,000 concurrent requests.
The operational cost of Protobuf is real. .proto files need to live somewhere, be versioned, and be shared across services. Adding a field is safe; removing one breaks consumers. Schema evolution requires coordination that JSON never demanded. For internal services where you control both ends, this is manageable. For anything touching external consumers, it becomes a permanent coordination tax.
Profile first. If encoding/json.Marshal appears at the top of your CPU flame graph, you have a serialization problem worth solving. If your DB driver appears there first and it usually does go fix your query plan.
A real example from a payment service
A fintech team I worked with ran a payment ledger service. Each transaction record was roughly 2KB as JSON. They were handling 8,000 transactions per second at peak a realistic number for an Indonesian payment processor during Harbolnas.
Their P99 latency was 180ms. They suspected JSON serialization. Their benchmarks looked bad in isolation, and the internet agreed: Protobuf would fix it.
They profiled before switching. Serialization was 4ms of the 180ms. The DB query was 160ms. A missing composite index on their ledger table was doing a sequential scan on 40 million rows every time a user checked their transaction history.
They added the index. P99 dropped to 22ms. Nobody touched Protobuf.
Six months later, at 80,000 transactions per second, they were finally CPU-bound on their serialization workers. Switching saved them two EC2 instances. The economics finally made sense.
The same lesson, twice over: understand your bottleneck before you optimize it. The fintech team wasn't wrong that Protobuf was faster. They were wrong about what "faster" would fix.
FAQ
Q: Is Protobuf always faster than JSON in Go?
A: For serialization in isolation, yes by a lot. Standard encoding/json uses runtime reflection; Protobuf's generated code doesn't. You'll typically see 5-8x faster serialization and payloads 40-60% smaller. Whether that difference changes your service's latency depends entirely on whether serialization is anywhere near your actual bottleneck.
Q: Do I need gRPC to use Protobuf?
A: No. Protobuf is a serialization format. You can use it over plain HTTP, TCP, or any transport layer. gRPC uses it by default because they were designed together, but they're independent. That said, if you're going to manage .proto files and a codegen pipeline anyway, gRPC usually makes the total investment worthwhile.
Q: Should I use Protobuf for my public-facing API?
A: Probably not. External consumers would need your .proto files, generated clients, and binary payloads they can't inspect with curl. JSON is the right default for anything external. Protobuf works best for internal service-to-service traffic where you control both ends of the wire.
Q: Is there a faster JSON library for Go that avoids the Protobuf complexity?
A: Yes. github.com/goccy/go-json is a drop-in replacement for encoding/json that benchmarks 2-4x faster. One import change. No schema files, no codegen, no cross-team coordination. If serialization is a real but minor bottleneck and you're not ready for Protobuf's operational overhead, start here.
Q: How do I know if serialization is actually my bottleneck?
A: Profile before guessing. Go's built-in pprof will show you where CPU time actually goes. If encoding/json.Marshal or encoding/json.Unmarshal sits at the top of your flame graph, you have a serialization problem worth solving. If your database driver or connection pool shows up first, fix those first.
Protobuf is faster than JSON. That's true and worth knowing.
What takes longer to learn is that "faster" only matters if the thing you're optimizing is actually what's slowing you down. Your flame graph knows. Your P99 knows. Your database index knows.
If you're at the scale where serialization genuinely shows up in your top CPU consumers, the switch is worth it and the math will work out. SpectreDev has helped teams make that call and helped others figure out why they didn't need to.
Internal links used:
- gRPC was designed around gRPC and Protobuf share a design history; natural anchor
- understand your bottleneck before you optimize it Pillar #1, anchored to the fintech example payoff
External links used:
- None technical claims sourced from Go documentation and widely reproduced benchmark methodology