Ethereum Already Has a Binary RPC. It's Just Not the One You Call.
A full Ethereum node listens on two ports that look like the same idea and are not.
8545 is JSON-RPC. Wallets, ethers.js, indexers, Infura-shaped products. Hex strings. Human-readable. Slow in the way every backend engineer has been told JSON is slow.
8551 is the Engine API. Consensus client talks to execution client, every slot, on the path that has to finish before the next block. Same machine. Different job.
I keep a bad habit: I read a sharp benchmark, see json.Marshal in a flame graph in someone else's post, and start shopping codecs. Protobuf. MessagePack. SSZ. Whatever the last impressive chart used. It feels like infrastructure work. Most of the time it is displacement activity.
So I did the measurement on the actual protocol, with real mainnet payloads, and then I went and read what Ethereum is already doing about it. The short version: the 3× win is real, and it still is not the first place to look. The interesting part is that the protocol has already picked a side — just not on the port you curl.
Two ports, two contracts
JSON-RPC on 8545 is a compatibility surface. Every client implements the same execution APIs. You can swap Geth for Reth and the wallet does not notice. Hex is the price of that: every hash, address, bloom filter and byte of calldata is 0x plus two ASCII characters per byte.
The Engine API on 8551 is the opposite contract. It runs between two processes you operate, once per 12-second slot, on the path that decides whether a payload is valid in time. The consensus layer already speaks SSZ. The execution layer still receives that payload as JSON-RPC with hex in the middle.
That mismatch is not a blogger's complaint. It is in the draft specs.
Binary data (hashes, addresses, transactions, blobs) is hex-encoded, doubling wire size. JSON parsing and generation adds CPU overhead on both sides… Payload sizes are reduced by ~50% compared to JSON-RPC, and serialization is no longer a bottleneck in the critical path between CL and EL.
EIP-7807 says the same thing in one line: move the engine API from textual JSON to binary SSZ, ~50% less data, “significantly improving encoding/parsing efficiency.” The Hegotá / Magicians writeup is blunter about when it starts to hurt:
The JSON based engine API starts to become a relevant bottleneck as the number of blobs increases and block data grows bigger.
So Ethereum is not confused about JSON. It is keeping JSON where strangers call, and trying to retire it where the slot clock is the SLA.
I still wanted the number for the public port. That is the one the fancy codec posts are actually talking about, even when they pretend they are talking about “Ethereum.”
What I measured
5.5 ms
JSON + gzip
full block, mean
1.6 ms
Protobuf + gzip
same blocks
~11%
Size left after gzip
123 KB → 110 KB
3.4×
CPU, not bytes
the number that survives gzip
One thousand real eth_getBlockByNumber(true) responses, gzip, mean:
Lower is better. The highlighted bar is what 8545 actually does. Schema-less binary (MessagePack, CBOR) already captures most of the win. Protobuf and SSZ refine it.eth-rpc-serialization-bench, run 20260814T042139Z, scale 1,000, gzip.
Data
| Value (µs) | |
|---|---|
| protobuf | 1,638 |
| SSZ | 1,781 |
| msgpack | 2,082 |
| CBOR | 2,565 |
| json-goccy | 4,392 |
| JSON | 5,497 |
The ratio does not depend on how many blocks you picked. It is the codec, not the window:
- JSON + gzip
- protobuf + gzip
Mean total cost per block. Flat lines: you do not need 10,000 blocks to learn this. You need one honest compressor and one honest payload.Same run. Scales 100 / 250 / 500 / 1,000.
Data
| blocks in the set | JSON + gzip (µs) | protobuf + gzip (µs) |
|---|---|---|
| 100 | 5,799 | 1,761 |
| 250 | 5,843 | 1,715 |
| 500 | 5,630 | 1,691 |
| 1000 | 5,497 | 1,638 |
| Format | Raw | Gzip | Encode | Compress | Total | vs JSON+gzip |
|---|---|---|---|---|---|---|
| protobuf | 239 KB | 110 KB | 103 µs | 1.54 ms | 1.64 ms | 0.30× CPU, 0.89× size |
| SSZ | 259 KB | 111 KB | 207 µs | 1.57 ms | 1.78 ms | 0.32× / 0.90× |
| MessagePack | 332 KB | 112 KB | 479 µs | 1.60 ms | 2.08 ms | 0.38× / 0.91× |
| CBOR | 299 KB | 112 KB | 956 µs | 1.61 ms | 2.56 ms | 0.47× / 0.91× |
| json-goccy | 557 KB | 123 KB | 1.28 ms | 3.12 ms | 4.39 ms | 0.80× / 1.00× |
| JSON | 557 KB | 123 KB | 2.37 ms | 3.12 ms | 5.50 ms | baseline |
Uncompressed, JSON is 2.3× protobuf. That is EIP-8178's “hex doubles the wire” in a stopwatch. After gzip the size story gets boring. The CPU story does not.
- encode
- gzip
Gzip dominates both bars. Protobuf still wins because it feeds gzip less ASCII and spends almost nothing on encode. Swapping JSON libraries (goccy) only trims the blue slice.Means, scale 1,000.
Data
| encode (µs) | gzip (µs) | |
|---|---|---|
| JSON | 2,372 | 3,125 |
| protobuf | 103 | 1,536 |
Receipts say the same thing in a fatter object (JSON raw 784 KB, gzip 77 KB): protobuf+gzip is 1.31 ms against JSON+gzip 5.23 ms.
Gzip ate the size argument
If your post stops at “JSON is verbose,” you are quoting the uncompressed column. Production RPC already speaks gzip. I know that from scars, not theory — bsc-geth #1801 is a production leak in JSON-RPC response gzip. Operators send Accept-Encoding: gzip. The comparison that matters is JSON+gzip versus binary+gzip.
gzip-best (level 9) is not a free upgrade. It is the worst row in the entire suite — slower than leaving JSON as JSON+gzip and walking away.JSON column only, scale 1,000.
Data
| Value (µs) | |
|---|---|
| none | 2,372 |
| snappy | 2,688 |
| zstd | 4,681 |
| gzip | 5,497 |
| gzip-best | 18,225 |
Best size on this set is MessagePack or protobuf with zstd (~106 KB). Best CPU if you can choose the compressor is protobuf+snappy (0.26 ms). The migration people actually propose — “swap JSON for protobuf, keep gzip” — is the 1.6 ms row. Real, not magical.
The rows that are not 1%
Blocks and receipts are the polite table. Logs and blobs are why the Engine API EIPs mention blobs by name.
The heavy JSON bar is the request. Protobuf stays at ~30 ms either way. This is the indexer firehose, not a wallet.Two eth_getLogs fixtures from the same 1,000-block window.
Data
| Value (ms) | |
|---|---|
| light / proto | 32 |
| light / JSON | 130 |
| heavy / proto | 30 |
| heavy / JSON | 909 |
A synthetic 24-blob-style bundle: JSON+gzip 112 ms, protobuf 1.0 ms. Gzip barely shrinks the binary (already incompressible 128 KiB blobs). It spends a fortune trying to shrink hex. That is the Magicians sentence with a clock on it.
The tail is a slope, not a rounding error. JSON's p95 is still 3× protobuf's p95.
Data
| mean (µs) | p95 (µs) | Ratio | |
|---|---|---|---|
| protobuf | 1,638 | 3,078 | 1.88× |
| SSZ | 1,781 | 3,255 | 1.83× |
| msgpack | 2,082 | 3,871 | 1.86× |
| JSON | 5,497 | 10,067 | 1.83× |
If you run an indexer, a debug/trace API, or anything that returns blobs as JSON hex, this is not a 1% story. It is the request.
Then I looked at the wrong flame graph on purpose
A 3× table is how codec posts get written. The next question is whether that table is the machine.
Marius van der Wijden's 2025 geth pprof — core developer, mainnet full sync — puts about 41% of CPU in InsertChain / the downloader and about 35% in applying transactions. That is EVM and state. It is not json.Marshal. ethereum.org on archive nodes says the same about sync: you are CPU-bound on execution. P2P is already RLP on the execution layer and SSZ on the consensus layer. Swapping 8545's codec does not make a node sync faster.
The people who do burn CPU on JSON have already given that job a separate process. Erigon ships rpcdaemon so the query layer can scale off the chain-follower. Under load, operators have watched it peg 8–9 cores with disk idle (erigon#2245). AWS literally sizes sync nodes and RPC nodes as different instance classes.
So the 3× win has an address. It lives on the RPC product, on fat methods, after the data is in memory. It does not live on a home validator. It does not live in snap sync. It is starting to live on 8551 as blobs grow — which is why the EIPs exist.
A blended public RPC mix is still mostly eth_call and small reads. Those requests spend their time in EVM and disk. Encode is a rounding error. A call-heavy fleet might see a few percent of box-level CPU from a codec change. A logs-and-blocks fleet might see more. I will not invent a single “Ethereum is 4% JSON” number. I do not have a production pprof of an Alchemy-shaped cluster, and neither do most of the posts that imply one.
The codec is not the first place to look
This is the part I am writing to my own hands.
I will open a “10× JSON” post, or an EIP that says hex doubles the wire, and feel underdressed if I am still on encoding/json. That instinct is how you spend a quarter replacing a protocol so you can avoid looking at the actual profile.
On almost every backend I have actually shipped, the order that moved the bill was:
- The query you should not be making. Chatty
Preloads, unboundedeth_getLogs,debug_traceon the hot path. - The disk and the pool. Cold page cache, unpinned connections, one AZ too far.
- The work inside the request. EVM, a lock, a regex, an N+1.
- Then, if a profile still says encode+gzip, the codec — and only on the pipe that owns that sample.
JSON on 8545 is a political choice dressed as a technical one. Changing it means every wallet, every SDK, every provider. The EIPs are not doing that. They are changing 8551, where both ends are your processes and the SLA is a slot.
If you only remember one structural fact from the table: MessagePack already got you most of the way, because the tax is hex, not the absence of a .proto file. Schema is optional. Stopping 0x is the migration.
Reproduce this
make bench-quickOffline path is synthetic. The numbers in this post are the Alchemy window above. Raw samples from that run live with the harness (results/<timestamp>/raw_samples.jsonl). Dollar figures in the harness report are estimates at $0.09/GB; I am not leaning on them here. The CPU table does not need a price to be true.
Four rules
-
A 3× codec win is a fact about a function, not about a machine. Multiply by the fraction of the box that is serialize+compress, or you are lying with a true table.
-
Look at the profile, the disk, and the query before you look at the codec. Codec shopping is what you do after those three are boring. I still get this backwards.
-
Gzip eats the size story and leaves the CPU story. Hex JSON looks twice as fat raw; after gzip a full block is ~11% smaller. Encode+compress time is still ~3×. Never quote a codec without naming the compressor. Never ship gzip-best because it sounds careful.
-
Keep JSON where strangers call. Fight hex where you own both ends. Port 8545 is a compatibility surface. Port 8551 is a slot. The EIPs are already picking SSZ for the second one. That is the jurisdiction.
I started this wanting a reason to replace JSON. I finished it with a map. The optimization is real. It has an address. For most people reading this — including me, on most Tuesdays — that address is not the public RPC port, and the first tool is not a new encoder.