Time to usable data
How long does a client spend turning bytes on the socket into a struct it can act on? Measured in Rust, TypeScript and Python, for raw Yellowstone protobuf against pre-parsed JSON.
We built this because we had been asserting a 15–30ms parse overhead figure across our marketing pages that was an estimate nobody could check. The measured answer turned out to be more interesting than the estimate, and it does not entirely favour us.
Protobuf and Borsh are not the expensive part
Strip pubkey encoding out of the raw path and it becomes faster than parsing the equivalent JSON — in every language we tested. 79.2–99.1% of the raw path is base58-encoding pubkeys, and almost none of it is the wire format everyone argues about.
Base 58 is not a power of two, so encoding means repeatedly dividing the whole number by 58, and integer division costs roughly 20–40 cycles. A Solana pubkey is 32 bytes and a trade event carries four of them — signature, fee payer, mint, user. That is a few thousand divisions per event regardless of language or library.
This reframes the case for pre-parsed delivery, including ours. It is not “protobuf is slow”. It is that you are paying to turn byte arrays into strings, once per pubkey, per event, forever — and that cost is only worth moving server-side if your code actually needs the strings. If your bot compares pubkeys as raw bytes and never renders them, you can skip base58 yourself and most of this saving disappears. Our own numbers say so, so we would rather you knew.
Results
Per event, median of 9 timed repetitions after a warmup. Both paths produce a field-identical struct, which every run asserts before timing anything.
| Language | Raw (full) | Raw, no base58 | Pre-parsed JSON | base58 share | Ratio |
|---|---|---|---|---|---|
TypeScript (Node) node 22.23.3 | 18.47 µs 18.47ms / 1k events | 732 ns protobuf + Borsh only | 1.05 µs 1.049ms / 1k events | 96% | 17.61x |
Python python 3.12.14 | 46.91 µs 46.908ms / 1k events | 9.74 µs protobuf + Borsh only | 3.77 µs 3.772ms / 1k events | 79.2% | 12.44x |
Rust cargo 1.98.1 (797e8a9bc 2026-08-05) (release, lto=true, opt-level=3) | 9.53 µs 9.527ms / 1k events | 85 ns protobuf + Borsh only | 1.11 µs 1.11ms / 1k events | 99.1% | 8.58x |
Not a like-for-like saving: the pre-parsed payload also drops pre/post balances and the other instructions in the transaction. If you need those, this gap will not hold for you.
2 threads, 7.8GB, linux 6.17.0-1022-azure x64. Run in CI, so figures are comparable across runs.
correctness: 2004 inputs match bs58 exactly throughput: ours 2664ns/key, bs58 2655ns/key, ratio 1.00x within 2x of bs58 — Path A timings are not dominated by harness overhead
How this is kept honest
A benchmark published by a vendor deserves suspicion, so four guards run on every execution and the run fails rather than reporting a flattering number.
- 1
Both paths must produce the same struct
Each harness decodes the first three events down both paths and asserts field-by-field equality before timing starts. This already caught a real bug in our own fixture generator, where a bitwise truncation wrapped large token amounts into negative numbers.
- 2
Our base58 is checked against the real library
Asserted byte-identical to the bs58 package across 2,000 inputs including leading-zero edge cases, then compared on throughput — and the run fails if we are more than 2x slower. Since base58 dominates the raw path, a deliberately slow implementation would be the easiest way to fake this entire result.
- 3
Median, not best-of
Every figure is the median of the timed repetitions after a warmup. Reporting the fastest run is how benchmarks end up irreproducible.
- 4
Fixtures are checksummed and regenerable
The manifest records the SHA-256 of both fixture files and every harness verifies them before running. Fixtures regenerate byte-identically from seed 42, so you can rebuild them and confirm the checksums match what we published.
Run it yourself
git clone https://github.com/subglow/subglow-benchmarks cd subglow-benchmarks npx tsx benchmarks/decode-cost/run-all.ts --reps 9 --events 20000
The harness, the wire-format spec, the raw CSV and the historical series are all in the repository. If a figure here is wrong, tell us and we will fix it and re-run: benchmarks@subglow.io.
What this does not measure
- • Cross-language comparisons. base58 is hand-rolled identically in all three languages so the work matches, which means these numbers do not tell you whether Rust beats Node at this task. Compare the two paths within one language only.
- • Wire latency. No network, TLS or gRPC framing — all identical on both paths. See the sourced provider matrix for why we do not publish cross-provider latency figures at all.
- • Real mainnet capture. Payloads are deterministic synthetic events modelled on a Pump.fun trade. The decode work is representative; exact byte counts depend on how many accounts a transaction touches.
- • Concurrency and backpressure. Single-threaded, in-memory. A saturated client behaves differently, and that is a separate test we have not built.
FAQ
How much overhead does client-side Borsh decoding add?
Less than we used to claim, and for a different reason than we used to give. Across the three languages measured, complete Path A decoding — protobuf, Borsh, base58, scaling — costs between 9.5ms and 46.9ms per 1,000 events, against 1.0ms to 3.8ms for pre-parsed JSON. But protobuf and Borsh are not what costs the money: with pubkey encoding removed, raw decoding is actually faster than parsing the equivalent JSON in every language we tested. 79.2–99.1% of Path A is base58-encoding pubkeys.
Why is base58 so expensive?
Because it is not a bit-shuffle, it is arithmetic. Base 58 is not a power of two, so encoding requires repeatedly dividing the whole number by 58 — and integer division is one of the slowest instructions on a modern CPU, roughly 20 to 40 cycles. A Solana pubkey is 32 bytes, and a typical trade event carries four of them once you count the signature, the fee payer, the mint and the user. That is a few thousand divisions per event, in any language, no matter how good your library is.
Does this mean pre-parsed JSON is not worth it?
It means the benefit is narrower than a headline ratio suggests, and you should check whether it applies to you. If your bot compares pubkeys as byte arrays and never renders them as strings, you can skip base58 yourself and the saving mostly disappears — our own numbers say so. If your code paths key maps by base58 string, log addresses, or hand them to an API, you are paying the full cost on every event and moving it server-side is a real win. We would rather you did that arithmetic than took the ratio at face value.
Why not benchmark wire latency across providers instead?
Because we cannot do it honestly. Cross-provider wire latency needs a paid account with every provider, is dominated by how far you sit from their nearest endpoint, and produces a different winner depending on where you run it. We do not hold accounts on most of the providers we compare against. Decode cost is the opposite: the payload format is public, so anyone can measure it and no competitor account is required. It is the one axis of this comparison where a fair number is cheap to produce.
How do I know these numbers are not rigged?
Check them — the harness, the spec and the raw CSV are in the repository, and the fixtures regenerate byte-identically from a seed so their checksums match the published manifest. Four guards run on every execution: both paths must produce field-identical structs before timing starts; our hand-rolled base58 is asserted byte-identical to the bs58 package and the run fails if it is more than 2x slower; every figure is a median rather than a best-of; and fixture checksums are verified. The base58 guard matters most, since base58 turns out to dominate — a deliberately slow implementation would be the easiest way to fake this result.
Can I compare Rust against TypeScript using these numbers?
No, and this is the harness's main limitation. Base58 is hand-rolled identically in all three languages so that the work being measured is the same, which means the numbers reflect our loop rather than what a tuned native library would do in each ecosystem. Compare Path A against Path B within one language. Comparing languages to each other using this data would be a misuse of it.