You don't need to rewrite it in Rust
I kept hitting the same wall from two directions. Indexers, backfills, anything that reads Ethereum logs by the million, and the slow part was always the same thing: decoding. Not fetching the logs over RPC, not writing rows to a database. Turning the raw topics and data of each log into something with named fields. In Node that goes through viem’s decodeEventLog, and viem is a good library, I reach for it every day. But hand it a million logs and it just sits there and burns CPU.
So I did the boring thing and measured it instead of guessing. I built a small benchmark that decodes the same batch of logs three ways and compared them honestly.
The setup
Three decoders, same input:
- viem (
decodeEventLog), plain Node, one log at a time. - A Rust CLI using alloy, called from Node as a child process. Node spawns it, Rust reads the file and decodes.
- A Rust N-API addon (napi-rs + alloy), the same decoder compiled as a native Node module and called in-process. No child process, just a function call across the JS/Rust boundary.
The data is synthetic but shaped like the real thing. ERC20 Transfer logs for the single-event case, and a mixed batch (Transfer, Approval, ERC1155 TransferSingle) for the realistic case where a stream carries more than one event type. The Rust side routes each mixed log to the right event by topic0, the same way you would in production. Timing is perf_hooks with a warmup pass first, so nobody pays a cold-start tax in the measured numbers.
The numbers
Logs decoded per second, higher is better. Measured on Apple Silicon, Node 26, Rust built --release.
| Batch | viem | alloy (CLI) | alloy (N-API) |
|---|---|---|---|
| 100k, single | 48,962 | 1,357,019 | 1,271,876 |
| 100k, mixed | 39,062 | 1,281,599 | 1,158,010 |
| 1,000,000, single | 48,826 | 1,469,161 | 1,269,916 |
| 1,000,000, mixed | 39,764 | 1,351,051 | 1,185,356 |
The thing that jumps out is that viem is flat. Around 45k logs per second whether you give it a hundred thousand or a million, single event or mixed. That flatness is the tell. The per-log cost is fixed, and no batch size makes it go away, so it’s a wall and not a warmup problem. In wall-clock terms a million logs is about 20 seconds in viem, 25 for the mixed batch. The same million in Rust finishes in under a second, so I ran it twice to make sure I hadn’t broken the loop and it was silently decoding nothing. It wasn’t.
So the native path is roughly 28 to 34 times faster on the same logs. Not 28% faster. 28 times.
What surprised me
The Rust I started with used ethabi, which is what everyone used to reach for. It’s unmaintained now, and the ecosystem has moved to alloy (alloy-json-abi and alloy-dyn-abi). I ported the decoder over expecting the numbers to be about the same, and alloy came out roughly 5x faster than the old ethabi run on identical data. So part of the win here isn’t Rust-in-general, it’s using the stack people actually maintain in 2026. Worth checking what you’re standing on before you benchmark it.
The other thing worth a look is the CLI-versus-addon gap, because there almost isn’t one. On a big batch the child-process path is a hair faster, because you spawn the process once and that cost gets spread across a million logs until it rounds to nothing. The N-API addon’s real advantage shows up in the opposite shape: many small calls, decoding a few hundred logs at a time on a hot path, where a child-process spawn per call would cost you more than the decoding does. So what decides it is your call pattern, not the bridge. Batch job, either is fine. In-process service decoding as logs arrive, use the addon.
The actual point
None of this means rewrite your indexer in Rust. I want to be clear about that, because it’s the wrong lesson to take from a 30x number.
Node and TypeScript are still the right default for most web3 backend work. The ecosystem is there, viem and its friends are genuinely good, and you move fast in it. Throwing that out to chase a benchmark on one function would be a bad trade, and you’d spend a month reimplementing things that already work. So don’t. Find the one hot spot that actually hurts, the decode loop here, and carve just that out into Rust. Call it from the same Node service. Everything else stays plain TypeScript, and viem still does the 95% of the work where its speed was never the problem.
I’ve written about this shape before, from the other side. A marketplace page that took up to two minutes to render turned out to be CPU-bound signature math holding Node’s single thread hostage. Same family of problem: heavy CPU work in the wrong place, everything else queued behind it. There the fix was getting the math off the event loop. Here it’s getting the decode out of JS entirely. Once you learn to see it, you stop reaching for “scale the service” and start asking which handful of functions are eating the CPU.
For a production indexer I’d ship the N-API addon. It decodes at over a million logs a second in-process, there’s no per-batch spawn, and the rest of the service doesn’t have to know Rust exists. It gets a decoded log back and moves on. You don’t rewrite the thing, you replace the one loop that was never going to be fast enough and leave everything else alone.
The benchmark is on GitHub if you want to run it on your own hardware: DeRain/evm-parser-performance-benchmark.