Skip to content
Star

How much does tracing slow down a viem transaction?

View as MarkdownOpen in ClaudeOpen in ChatGPT

hashspan adds about 0.25 ms at p50 to a viem send plus its receipt wait on a local chain, and about 0.5 ms when viem polls for the receipt. It adds one JSON-RPC request per send, none if you pass the nonce, and three for a reverted transaction.

  • Latency: +0.25 ms at p50 (0.21 to 0.31 ms over 3 runs of 500), +0.5 ms when viem polls. The p95 gap is inside run-to-run noise.
  • Requests: +1 eth_getTransactionByHash, off your call’s path. 0 with nonce passed. +3 for a revert, two of them to decode the reason.
  • On Base: a block is 2,000 ms. 0.25 to 0.5 ms is 0.01 to 0.03 % of it.

Measured on 2026-10-21 with the published @hashspan/viem 1.3.1 and viem 2.57.7, on an Apple M3 Pro. “A transaction” here always means sendTransaction plus waitForTransactionReceipt, the time your code waits for.

How much latency does hashspan add to a viem transaction?

Section titled “How much latency does hashspan add to a viem transaction?”
Bar chart of a viem send plus receipt on a local Anvil chain, 3 runs of 500. At p50: plain viem 2.48 ms, hashspan with a no-op exporter 2.75 ms (+0.28), hashspan with batch OTLP export 2.72 ms (+0.25), a revert 1.06 ms plain and 1.33 ms traced (+0.26). At p95: 4.91, 5.47, 5.54, 2.85 and 3.46 ms. The p95 gaps overlap run-to-run noise of -0.3 to +1.0 ms.Bar chart of a viem send plus receipt on a local Anvil chain, 3 runs of 500. At p50: plain viem 2.48 ms, hashspan with a no-op exporter 2.75 ms (+0.28), hashspan with batch OTLP export 2.72 ms (+0.25), a revert 1.06 ms plain and 1.33 ms traced (+0.26). At p95: 4.91, 5.47, 5.54, 2.85 and 3.46 ms. The p95 gaps overlap run-to-run noise of -0.3 to +1.0 ms.

Each value is the mean of the 3 per-run percentiles, in milliseconds, with the receipt already mined when the wait starts (ready mode, below).

Scenario p50 p95
plain viem 2.48 4.91
plain viem again (A/A) 2.49 5.18
hashspan, no-op exporter 2.75 5.47
hashspan, batch + OTLP 2.72 5.54
plain, nonce passed 2.49 5.22
hashspan, nonce passed 2.66 5.31
revert, plain 1.06 2.85
revert, hashspan 1.33 3.46

The gap to plain viem, mean and the min to max over the 3 runs:

Gap Δ p50, ms Δ p95, ms
A/A noise 0.01 (−0.02 to 0.05) 0.27 (−0.32 to 1.03)
no-op exporter 0.28 (0.25 to 0.30) 0.55 (0.10 to 1.12)
batch + OTLP 0.24 (0.21 to 0.27) 0.62 (−0.04 to 1.63)
nonce passed 0.17 (0.15 to 0.20) 0.09 (−0.24 to 0.41)
revert 0.26 (0.21 to 0.31) 0.61 (0.13 to 1.49)

With batch export, the p50 gap splits into +0.13 ms on the send and +0.11 ms on the wait. Passing nonce drops the send part to +0.06 ms. In ready mode, batch export to an OTLP endpoint cost no more at p50 than the no-op exporter: the BatchSpanProcessor exports off the call’s path.

The p50 gap is real. Every scenario ran next to a second, identical plain viem scenario, an A/A control. Their p50s differed by −0.02 to +0.05 ms, while every traced p50 gap was 0.15 ms or more, in every run.

The tails are not resolved. p95 rose in 8 of the 9 runs of the no-op, batch and revert scenarios, by about 0.6 ms on average. But plain viem against itself moved by up to 1.0 ms at p95, and 2.6 ms at p99. This run can’t separate a small tail cost from noise, in either direction.

How many extra JSON-RPC requests does hashspan make?

Section titled “How many extra JSON-RPC requests does hashspan make?”
JSON-RPC requests per transaction, counted at the transport. A transfer where viem picks the nonce: 3 plain calls (eth_fillTransaction, eth_sendRawTransaction, eth_getTransactionReceipt) plus 1 eth_getTransactionByHash from tracing. A transfer with the nonce passed: no extra call. A reverted transaction: 3 extra, two eth_getTransactionByHash and one eth_call. Same counts in all 3 runs.JSON-RPC requests per transaction, counted at the transport. A transfer where viem picks the nonce: 3 plain calls (eth_fillTransaction, eth_sendRawTransaction, eth_getTransactionReceipt) plus 1 eth_getTransactionByHash from tracing. A transfer with the nonce passed: no extra call. A reverted transaction: 3 extra, two eth_getTransactionByHash and one eth_call. Same counts in all 3 runs.
Case Added To drop it
send, viem picks the nonce 1 pass nonce
send, nonce passed 0
reverted transaction 3 decodeRevertReason: false drops 2

The eth_getTransactionByHash runs off your call’s path. It reads the nonce and sender that hashspan needs to spot a replaced transaction. On a reverted transaction, one more lookup and an eth_call replay the call to decode the revert reason. The counts were the same in all 3 runs.

On a rate-limited endpoint, this matters more than the milliseconds. The architecture page lists the requests added in every other case, such as watch() and preconfirmed receipts.

What changes when viem polls for the receipt?

Section titled “What changes when viem polls for the receipt?”

In a normal app, the wait starts right after the send and viem polls. This run did that too (e2e mode, 4 runs of 300, polling every 10 ms):

Gap Δ p50, ms Δ p95, ms
A/A noise 0.06 (−0.01 to 0.10) 0.14 (−0.70 to 1.20)
no-op exporter 0.50 (0.44 to 0.63) 0.65 (−0.24 to 2.04)
batch + OTLP 0.56 (0.44 to 0.61) 0.68 (0.12 to 1.07)
nonce passed 0.35 (0.27 to 0.46) 0.56 (0.18 to 1.14)
revert 0.51 (0.47 to 0.58) 0.81 (0.40 to 1.11)

Plain viem took 14.48 ms at p50 here. The gap is about twice the ready-mode gap, also with nonce passed. Part of it is likely the extra lookup sharing one single-threaded local node with the polling. This run doesn’t isolate the rest.

How much memory does hashspan keep per transaction?

Section titled “How much memory does hashspan keep per transaction?”
Gap Heap kept per transaction
no-op exporter 1.08 KB (0.95 to 1.31)
batch + OTLP 1.37 KB (1.16 to 1.56)
revert 2.12 KB (1.60 to 2.39)

That’s what the tracker stores to link a confirm span to its send. By default it keeps each send for 10 minutes and at most 10,000 sends (linkTtlMs and maxTrackedTransactions), so it is bounded.

Base builds a block every 2 seconds, and Flashblocks cut that to 200 ms preconfirmations. Base’s docs: “This reduces effective block times from 2 seconds to 200 milliseconds through preconfirmations” (transaction ordering).

So 0.25 to 0.5 ms is 0.01 to 0.03 % of a Base block, and 0.1 to 0.3 % of a flashblock. The +10 % you can compute from the table above is large only because a local transaction takes 2.5 ms. On a real network, the RPC round trip and the block time come on top. hashspan’s own work stays in your process; only its extra request goes over the network.

Every scenario sends a 1 wei transfer from a local account (viem signs, then calls eth_sendRawTransaction) and waits with waitForTransactionReceipt. Traced scenarios extend both clients with one withHashspan({ tracerProvider, meterProvider }).

Scenario Telemetry
plain viem, twice (A/A) none
hashspan, no-op exporter SimpleSpanProcessor to an exporter that drops spans: the instrumentation alone
hashspan, batch + OTLP BatchSpanProcessor and OTLP/HTTP traces, plus OTLP metrics every 5 s, to a sink in another process
nonce passed plain and batch + OTLP, with nonce set by the caller
revert a call to a contract that reverts with Error("nope"), plain and batch + OTLP
  • Order: each round runs one operation of every scenario, in a new random order. Each scenario has its own dev account.
  • Size: 30 warm-up rounds, then 500 measured rounds in ready mode and 300 in e2e mode. 3 ready runs and 4 e2e runs, each with a fresh Anvil and a fresh Node process.
  • Ready mode: after the send, the benchmark waits, untimed, until Anvil has the receipt. The timed wait is then one receipt read plus hashspan’s work, with no polling sleep in it. Anvil mines about 5 ms after the send, so without this viem’s first read usually misses.
  • E2E mode: the wait starts right after the send, and viem polls every 10 ms.
  • Settling: after every operation, outside the timer, the benchmark calls flush() and waits for in-flight requests. One scenario’s background work can’t land in another’s timing.
  • Proof the tracing ran: every traced run exported exactly 2 spans per operation, warm-up included (1,060 of 1,060 per ready run). Every revert’s confirm span carried blockchain.tx.revert.reason = nope.
  • Memory: a separate pass, one scenario per fresh node --expose-gc process: 30 warm-up operations, gc(), 500 operations, gc(), and the heapUsed difference, 3 times. Traced minus plain.

Timing uses process.hrtime.bigint() around the send and the wait. JSON-RPC requests are counted by a wrapper around viem’s http() transport.

Date 2026-10-21
Machine Apple M3 Pro, 12 cores, 18 GiB RAM, macOS 26.7.1
Node.js 22.14.0
viem 2.57.7
hashspan @hashspan/viem 1.3.1, @hashspan/core 1.3.1, from npm
OpenTelemetry @opentelemetry/api 1.9.1, sdk-trace-base 2.12.0, sdk-metrics 2.12.0, OTLP/HTTP exporters 0.223.0
Chain Anvil 1.8.5, automine, chain id 31337
  • A local chain. A receipt read from Anvil takes about 0.24 ms. A real endpoint answers in tens of ms or more, so the relative overhead there is far smaller, and each extra request costs more.
  • One machine. An M3 Pro is fast. A slower machine probably shows a larger absolute gap; it wasn’t measured.
  • A local OTLP sink. It answers at once. A remote collector adds network work to the exporter, but in the batch processor, off the call’s path.
  • One transaction at a time. There is no concurrency here, so this says nothing about throughput under load.
  • One discarded attempt. The first e2e attempt stopped when one receipt wait hit viem’s default 180 s timeout. Its data was never written. The reruns used a 30 s timeout and had no failure, nor did any ready run.

This run covers sends you wait for with waitForTransactionReceipt. It doesn’t cover background confirmation, watch(), preconfirmed receipts, the OP Stack operator fee lookup, user operations or payments. Each makes its own requests, listed on the architecture page.

This is a reduced version of the benchmark script behind the numbers above: plain viem twice and hashspan with a no-op exporter, in a new random order every round, after 30 warm-up rounds. viem polls for the receipt, so compare its gap with the polling table. Start Anvil with anvil, then:

Terminal window
npm i viem@2.57.7 @hashspan/viem@1.3.1 @opentelemetry/api@1.9.1 @opentelemetry/sdk-trace-base@2.12.0
node bench.mjs 500
bench.mjs
// Plain viem vs viem + hashspan on a local Anvil: send + receipt, p50 and p95.
import { createPublicClient, createWalletClient, http } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
import { foundry } from 'viem/chains';
import { withHashspan } from '@hashspan/viem';
import { BasicTracerProvider, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base';
const url = 'http://127.0.0.1:8545';
const keys = [ // Anvil's default dev accounts 1-3, local chain only
'0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d',
'0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a',
'0x7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6',
];
const dropAll = { export: (_spans, done) => done({ code: 0 }), shutdown: async () => {} };
function scenario(name, key, traced) {
let wallet = createWalletClient({ account: privateKeyToAccount(key), chain: foundry, transport: http(url) });
let reader = createPublicClient({ chain: foundry, transport: http(url), pollingInterval: 10 });
const hs = traced && withHashspan({ tracerProvider: new BasicTracerProvider({ spanProcessors: [new SimpleSpanProcessor(dropAll)] }) });
if (hs) { wallet = wallet.extend(hs); reader = reader.extend(hs); }
const ms = [];
return { name, ms, async op() {
const t0 = performance.now();
const hash = await wallet.sendTransaction({ to: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', value: 1n });
await reader.waitForTransactionReceipt({ hash });
ms.push(performance.now() - t0);
if (hs) await hs.flush(); // settle off-path work outside the timer
} };
}
const all = [scenario('plain', keys[0], false), scenario('plain again', keys[1], false), scenario('hashspan', keys[2], true)];
const [warmup, rounds] = [30, Number(process.argv[2] ?? 500)];
for (let r = 0; r < warmup + rounds; r++) {
const order = all.map((s) => [Math.random(), s]).sort((a, b) => a[0] - b[0]);
for (const [, s] of order) await s.op(); // a new random order every round
if (r === warmup - 1) all.forEach((s) => (s.ms.length = 0));
}
const q = (xs, p) => [...xs].sort((a, b) => a - b)[Math.floor((xs.length - 1) * p)];
for (const s of all) console.log(s.name.padEnd(12), 'p50', q(s.ms, 0.5).toFixed(2), 'ms p95', q(s.ms, 0.95).toFixed(2), 'ms');

Run it a few times. If “plain” and “plain again” differ by as much as plain and hashspan do, your machine is too noisy for the comparison: close other apps and run more rounds.

Does OpenTelemetry tracing slow down viem transactions?

A little. On a local Anvil chain, @hashspan/viem 1.3.1 added about 0.25 ms at p50 to a send plus receipt wait when the receipt was already there, and about 0.5 ms when viem had to poll for it. A Base block is 2,000 ms, so that is 0.01 to 0.03 % of a block.

Does hashspan add JSON-RPC requests?

One eth_getTransactionByHash per send, made off your call's path, so it can link a replacement to the original. None when you pass nonce yourself. A reverted transaction gets 3 in this run: that lookup, plus 2 to replay the call and decode the revert reason, which decodeRevertReason: false turns off.

Is the p95 or p99 latency worse with tracing?

This run can't tell. The p95 gap was about +0.6 ms on average, but plain viem measured against itself moved by up to 1.0 ms at p95 between runs. The tail gaps are inside that noise, so they are neither proven nor ruled out.

How much memory does hashspan use per transaction?

About 1.1 to 1.4 KB of heap per transaction in this run, 2.1 KB for a revert. The tracker keeps each send for linking: 10 minutes and at most 10,000 sends by default (linkTtlMs and maxTrackedTransactions), so memory stays bounded.

Links from the answers: viem adapter options (decodeRevertReason), core options (linkTtlMs, maxTrackedTransactions), tracing viem with OpenTelemetry.