Anvil & EVM Benchmarks
How to benchmark Ethereum client libraries using a local Anvil node
std::anvil lets you spawn a local Anvil node — a fast in-process Ethereum simulator from the Foundry toolkit — and benchmark RPC calls against it. poly-bench handles spawning, port allocation, URL injection, and cleanup automatically.
Benchmarking against a remote RPC endpoint introduces network jitter that drowns out the library overhead you actually want to measure. Anvil runs locally, responds in microseconds, and can fork mainnet state so your benchmarks operate on real chain data without hitting rate limits.
Import the module at the top of your .bench file:
1use std::anvilglobalSetup vs Suite-level SetupAnvil should be spawned in a globalSetup block — either at the file level (shared across all suites) or inside a single suite. Both are equivalent when you have one suite.
1use std::anvil2
3# Spawned once — available to every suite in this file4globalSetup {5 anvil.spawnAnvil(fork: "https://eth.llamarpc.com")6}7
8suite suiteA {9 # ... can use ANVIL_RPC_URL here10}11
12suite suiteB {13 # ... can also use ANVIL_RPC_URL here14}1globalSetup {2 # No fork — blank chain, no real state3 anvil.spawnAnvil()4}getBalance, getLogs). Use a local node for pure transaction encoding/signing benchmarks that don't need chain state.ANVIL_RPC_URL — Connecting Your ClientsAfter spawnAnvil() runs, the local RPC endpoint is injected into your setup code. The typical value is http://127.0.0.1:8545.
ANVIL_RPC_URL is available as a module-level constant in the declare scope — no process.env needed.
1setup ts {2 import {3 import { createPublicClient, http } from 'viem'4 import { mainnet } from 'viem/chains'5 }6
7 declare {8 let publicClient: any9 }10
11 init {12 publicClient = createPublicClient({13 chain: mainnet,14 transport: http(ANVIL_RPC_URL), # injected constant15 })16 }17
18 helpers {19 async function getBlockNumber(): Promise<bigint> {20 return await publicClient.getBlockNumber()21 }22
23 async function getBalance(addr: string): Promise<bigint> {24 return await publicClient.getBalance({25 address: addr as `0x${string}`26 })27 }28 }29}Access via os.Getenv("ANVIL_RPC_URL") or pass the string directly.
1setup go {2 import (3 "context"4 "math/big"5 "os"6 "github.com/ethereum/go-ethereum/common"7 "github.com/ethereum/go-ethereum/ethclient"8 )9
10 declare {11 var ethClient *ethclient.Client12 var ctx context.Context13 }14
15 init {16 ctx = context.Background()17 rpcURL := os.Getenv("ANVIL_RPC_URL")18 ethClient, _ = ethclient.Dial(rpcURL)19 }20
21 helpers {22 func getBlockNumber() uint64 {23 num, _ := ethClient.BlockNumber(ctx)24 return num25 }26
27 func getBalance(addr string) *big.Int {28 balance, _ := ethClient.BalanceAt(29 ctx,30 common.HexToAddress(addr),31 nil,32 )33 return balance34 }35 }36}1setup go {2 import (3 "context"4 "os"5 "github.com/ChefBingbong/viem-go/chain/definitions"6 "github.com/ChefBingbong/viem-go/client"7 "github.com/ChefBingbong/viem-go/client/transport"8 )9
10 declare {11 var publicClient *client.PublicClient12 var ctx context.Context13 }14
15 init {16 ctx = context.Background()17 rpcURL := os.Getenv("ANVIL_RPC_URL")18
19 _client, err := client.CreatePublicClient(client.PublicClientConfig{20 Chain: &definitions.Mainnet,21 Transport: transport.HTTP(rpcURL),22 })23 if err != nil {24 panic("failed to create client: " + err.Error())25 }26 publicClient = _client27 }28
29 helpers {30 func getBlockNumber() any {31 num, err := publicClient.GetBlockNumber(ctx)32 if err != nil {33 return err34 }35 return num36 }37 }38}1setup rust {2 import {3 use alloy::providers::{Provider, ProviderBuilder};4 use alloy::primitives::Address;5 use std::str::FromStr;6 }7
8 helpers {9 fn get_rpc_url() -> String {10 std::env::var("ANVIL_RPC_URL")11 .unwrap_or_else(|_| "http://127.0.0.1:8545".to_string())12 }13
14 async fn get_block_number() -> u64 {15 let provider = ProviderBuilder::new()16 .on_http(get_rpc_url().parse().unwrap());17 provider.get_block_number().await.unwrap()18 }19 }20}A complete example benchmarking three RPC operations across Go and TypeScript.
1use std::anvil2use std::charting3
4globalSetup {5 anvil.spawnAnvil(fork: "https://eth.llamarpc.com")6}7
8suite rpcBench {9 description: "Ethereum RPC benchmarks — go-ethereum vs viem"10 compare: true11 baseline: "go"12 mode: "auto"13 targetTime: 10000ms # RPC calls are slow; give them time to stabilize14 cvThreshold: 10 # Allow higher variance for network operations15 sink: false # RPC results are already side-effectful16
17 setup go {18 import (19 "context"20 "math/big"21 "os"22 "github.com/ethereum/go-ethereum/common"23 "github.com/ethereum/go-ethereum/ethclient"24 )25
26 declare {27 var ethClient *ethclient.Client28 var ctx context.Context29 var vitalik = common.HexToAddress("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045")30 }31
32 init {33 ctx = context.Background()34 ethClient, _ = ethclient.Dial(os.Getenv("ANVIL_RPC_URL"))35 }36
37 helpers {38 func getBlockNumber() uint64 {39 num, _ := ethClient.BlockNumber(ctx)40 return num41 }42
43 func getBalance(addr common.Address) *big.Int {44 bal, _ := ethClient.BalanceAt(ctx, addr, nil)45 return bal46 }47
48 func getChainID() *big.Int {49 id, _ := ethClient.ChainID(ctx)50 return id51 }52 }53 }54
55 setup ts {56 import {57 import { createPublicClient, http } from 'viem'58 import { mainnet } from 'viem/chains'59 }60
61 declare {62 let client: any63 const VITALIK = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'64 }65
66 init {67 client = createPublicClient({68 chain: mainnet,69 transport: http(ANVIL_RPC_URL),70 })71 }72
73 helpers {74 async function getBlockNumber(): Promise<bigint> {75 return await client.getBlockNumber()76 }77
78 async function getBalance(addr: string): Promise<bigint> {79 return await client.getBalance({ address: addr as `0x${string}` })80 }81
82 async function getChainId(): Promise<number> {83 return await client.getChainId()84 }85 }86 }87
88 bench getBlockNumber {89 description: "Get the current block number"90 go: getBlockNumber()91 ts: await getBlockNumber()92 }93
94 bench getBalance {95 description: "Get ETH balance of vitalik.eth"96 go: getBalance(vitalik)97 ts: await getBalance(VITALIK)98 }99
100 bench getChainId {101 description: "Get the chain ID"102 go: getChainID()103 ts: await getChainId()104 }105
106 after {107 charting.drawTable(108 title: "RPC Performance — go-ethereum vs viem",109 output: "rpc-bar.svg",110 sortBy: "speedup",111 sortOrder: "desc"112 )113 }114}stopAnvil() and Cleanuppoly-bench automatically terminates the Anvil process when the run completes, so stopAnvil() is not required. Call it explicitly if you want to stop Anvil before the after block finishes — for example, to ensure it's stopped before chart generation.
1after {2 anvil.stopAnvil() # Optional — poly-bench cleans up automatically3
4 charting.drawTable(5 title: "RPC Results",6 output: "rpc-bar.svg"7 )8}Use a long targetTime
RPC calls are orders of magnitude slower than in-memory operations. Set targetTime to at least 5000ms–10000ms so the auto-calibrator can gather enough samples.
1suite rpcBench {2 mode: "auto"3 targetTime: 10000ms # 10 seconds per benchmark4 minIterations: 50 # At least 50 RPC calls5}Relax cvThreshold
Network operations have inherent variance. A cvThreshold of 10–15 is realistic for RPC benchmarks; the default of 5 may cause excessive re-runs.
1suite rpcBench {2 cvThreshold: 10 # Accept up to 10% coefficient of variation3}Disable sink for RPC calls
The black-box sink is designed to prevent dead-code elimination of pure computations. RPC calls have real side effects and don't need it.
1suite rpcBench {2 sink: false3}Use declare for client variables
Client objects must be declared at the module/package level so they're accessible from both init and helpers. Always use declare for this.
1setup ts {2 declare {3 let client: any # declared at module level4 }5
6 init {7 client = createPublicClient(...) # assigned in init8 }9
10 helpers {11 async function getBlock() {12 return await client.getBlock() # accessible here13 }14 }15}declare, init, helpers