A fast response to eth_blockNumber tells you how quickly an endpoint can return a small piece of data. It does not tell you when that endpoint learned about the block, whether twenty pool reads will finish before your deadline, or whether the transaction you submit will reach a builder in time.

Our free-tier RPC comparison measured request latency, burst capacity and head delivery separately. This post is the method you can apply to your own bot. It is not a new provider ranking, and it does not reuse those measurements as if they were taken today.

The unit that matters is a completed decision on the right state. Start there, then work backwards through the requests that decision needs.

Put clocks around the bot, not just the endpoint

An arbitrage loop has several distinct waits. A head notification reaches your process. The process reads the pools, evaluates candidate routes, simulates an executor call, signs a transaction and submits it. The submission endpoint acknowledges the request. Inclusion comes later and is a separate outcome.

Record timestamps at those boundaries using one monotonic clock, such as performance.now() or process.hrtime.bigint(). Keep a wall-clock timestamp too, for correlating logs, but do not subtract unsynchronized wall clocks on different hosts and call the difference network latency.

A trading bot’s measurement boundaries: head received, state reads completed, simulation completed and submission acknowledged, with transaction inclusion tracked separately rather than counted as another RPC response

Measurement What it answers What it does not answer
Head arrival relative to another feed Which connection delivered this block hash first to this process? When the block was first seen anywhere on Ethereum
State-read batch completion When did all required inputs become available? Whether those inputs describe the same state
Executor simulation duration How long did this exact call take to evaluate? Whether it will succeed after other transactions execute
Submission acknowledgement When did the recipient accept or reject the request? Whether a builder included it
Canonical receipt and execution result Did the transaction land and succeed? Whether its realized profit met the strategy’s target

Attach the block hash, route identifier and transaction hash to the relevant records. Without that identity, a fast answer from an older head can look like a better endpoint.

Make state reads comparable

Using latest on every request is convenient, but the word can refer to a different block by the time each endpoint answers. Even two reads through one provider can straddle a head change.

For a controlled RPC benchmark, select one recent block and use its hash for every state query. EIP-1898 defines a block parameter object containing blockHash and requireCanonical for eth_call and other state methods. Supply the complete hash returned by the node and set requireCanonical to true. The canonical requirement makes a node reject a block it knows is no longer canonical instead of silently serving orphaned state.

Not every endpoint implements that parameter correctly. Treat rejection as a compatibility result. Do not silently fall back to latest, because that changes the experiment. A number-pinned alternative needs hash checks around the reads and has weaker guarantees during a reorg.

Pinning state also makes the benchmark cache-friendly. That is useful for measuring repeated reads, but it is not representative of every new block. Run a separate test that advances the anchor with the head and records how quickly the new state becomes queryable. Keep those results in separate tables.

A small, read-only HTTP benchmark

The program below measures one eth_call against a fixed recent block, rotating the endpoint that goes first each round. It has no wallet, sends no transactions, and needs Node.js 22 or later. Save it as rpc-bench.mjs.

This is an HTTP baseline, not a WebSocket or subscription benchmark. It uses Node’s built-in connection pooling, does one warm-up call per endpoint, and sends one request at a time. It does not prove that every request reused a socket. Record transport-level evidence separately if your report claims verified connection reuse.

import { readFile } from 'node:fs/promises';
import { performance } from 'node:perf_hooks';

const endpoints = JSON.parse(await readFile(process.argv[2], 'utf8'));
const call = JSON.parse(await readFile(process.argv[3], 'utf8'));
const rounds = Number(process.env.ROUNDS ?? 100);

if (!Number.isSafeInteger(rounds) || rounds < 1 || rounds > 10000) {
  throw new Error('ROUNDS must be an integer from 1 to 10000');
}

if (!Array.isArray(endpoints) || endpoints.length === 0) {
  throw new Error('Provide at least one endpoint');
}

for (const endpoint of endpoints) {
  const url = new URL(endpoint.url);

  if (!['http:', 'https:'].includes(url.protocol)) {
    throw new Error('This benchmark accepts HTTP(S) endpoints only');
  }

  if (typeof endpoint.name !== 'string' || !endpoint.name) {
    throw new Error('Each endpoint needs a nonempty name');
  }
}

if (new Set(endpoints.map(e => e.name)).size !== endpoints.length) {
  throw new Error('Endpoint names must be unique');
}

if (
  !/^0x[0-9a-fA-F]{40}$/.test(call.to ?? '') ||
  !/^0x(?:[0-9a-fA-F]{2})*$/.test(call.data ?? '')
) {
  throw new Error('Call needs a to address and hex data');
}

let id = 0;

async function rpc(endpoint, method, params) {
  const requestId = ++id;
  let response;

  try {
    response = await fetch(endpoint.url, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ jsonrpc: '2.0', id: requestId, method, params }),
      signal: AbortSignal.timeout(5000),
    });
  } catch (error) {
    throw new Error(error.name === 'TimeoutError' ? 'timeout' : 'transport');
  }

  if (!response.ok) {
    await response.body?.cancel();
    throw new Error(`HTTP ${response.status}`);
  }

  const body = await response.json();

  if (body.id !== requestId) {
    throw new Error('response id mismatch');
  }

  if (body.error) {
    throw new Error(`RPC ${body.error.code}`);
  }

  if (!Object.hasOwn(body, 'result')) {
    throw new Error('missing result');
  }

  return body.result;
}

for (const endpoint of endpoints) {
  if (await rpc(endpoint, 'eth_chainId', []) !== '0x1') {
    throw new Error(`${endpoint.name}: expected Ethereum mainnet`);
  }
}

const anchor = await rpc(endpoints[0], 'eth_getBlockByNumber', ['latest', false]);

if (!anchor?.hash) {
  throw new Error('No anchor block returned');
}

// EIP-1898: supply the block hash and require it to be canonical.
const params = [call, { blockHash: anchor.hash, requireCanonical: true }];
let expected;

// Warm each endpoint and reject mismatched state before collecting timings.
for (const endpoint of endpoints) {
  const result = await rpc(endpoint, 'eth_call', params);
  if (typeof result !== 'string' || !/^0x(?:[0-9a-fA-F]{2})+$/.test(result)) {
    throw new Error(`${endpoint.name}: expected nonempty call return data`);
  }
  if (expected !== undefined && result.toLowerCase() !== expected) {
    throw new Error(`${endpoint.name}: warm-up result differs`);
  }
  expected = result.toLowerCase();
}

const records = [];

for (let round = 0; round < rounds; round++) {
  for (let offset = 0; offset < endpoints.length; offset++) {
    // Rotate which endpoint goes first each round to avoid a systematic bias
    // from ordering effects.
    const endpoint = endpoints[(round + offset) % endpoints.length];
    const start = performance.now();
    let error = null;

    try {
      const result = await rpc(endpoint, 'eth_call', params);

      if (typeof result !== 'string' || result.toLowerCase() !== expected) {
        throw new Error('result differs');
      }
    } catch (failure) {
      error = failure.message;
    }

    records.push({
      name: endpoint.name,
      round,
      ms: performance.now() - start,
      error,
    });
  }
}

// Keep failures in the report, but compute percentiles from successful calls.
function percentile(sorted, p) {
  // Nearest-rank: for N sorted samples, the index is ceil(N * p) - 1.
  return sorted.length ? sorted[Math.ceil(sorted.length * p) - 1] : null;
}

const summary = endpoints.map(endpoint => {
  const rows = records.filter(row => row.name === endpoint.name);
  const times = rows
    .filter(row => row.error === null)
    .map(row => row.ms)
    .sort((a, b) => a - b);

  return {
    name: endpoint.name,
    attempts: rows.length,
    successes: times.length,
    failures: rows.length - times.length,
    p50: percentile(times, 0.5),
    p95: percentile(times, 0.95),
    p99: percentile(times, 0.99),
  };
});

console.log(
  JSON.stringify(
    {
      recordedAt: new Date().toISOString(),
      node: process.version,
      blockNumber: anchor.number,
      blockHash: anchor.hash,
      transport: 'HTTP(S)',
      concurrency: 1,
      rounds,
      summary,
      records,
    },
    null,
    2
  )
);

Create endpoints.json with names and URLs for endpoints you have permission to query. Keep API keys out of version control and published results. For a locally exposed mainnet node, the file can be:

[
  { "name": "local-http", "url": "http://127.0.0.1:8545" }
]

Create call.json. This example reads getReserves() from the Ethereum mainnet Uniswap V2 USDC/WETH pair, the same read used in our earlier comparison:

{
  "to": "0xb4e16d0168e52d35cacd2c6185b44281ec28c9dc",
  "data": "0x0902f1ac"
}

Run it with:

ROUNDS=100 node rpc-bench.mjs endpoints.json call.json > results.json

The program checks the chain, warms each endpoint, and requires identical nonempty return data before measuring. An endpoint that cannot serve the anchor aborts setup. Wait for it to catch up and rerun, or report it separately. Failures during measurement remain in the raw records and the failure count. Percentiles describe successful responses only, which is why the count belongs beside them.

One hundred successful samples make nearest-rank p99 the 99th sorted sample. That is a thin tail estimate, not a stable promise. Repeat runs, retain the raw records and increase sample size within your provider’s limits. A timeout is a failed request with a deadline, not a successful five-second response.

Replace the easy call with your actual workload

A reserve read is a useful transport baseline. It is not a route simulation. Change call.json to the populated call your bot actually makes, including from, value, gas and calldata when those fields affect execution. The harness expects a deterministic, nonempty return value; it deliberately rejects calls whose only successful return is 0x. Use a dedicated simulation measurement for those calls rather than weakening the baseline check.

For a full executor simulation, retain the distinction between an expected strategy revert and an infrastructure failure. A route that has become unprofitable may correctly revert. A timed-out request gives no answer. Combining the two into one generic error rate makes it impossible to tell whether you need a better strategy or a different RPC path.

Also inspect your library settings. ethers v6 batches JSON-RPC requests by default and exposes provider cache settings. A tight loop around a high-level method can measure client behavior as well as the network. That may be appropriate for your bot, but label it as an application-level test. The program above uses raw JSON-RPC without a library cache or JSON-RPC batching layer; the provider can still cache responses.

Measure the burst that follows a new block

Once the single-request path is understood, reproduce your real read fan-out. If a strategy needs twelve pool reads before evaluating a route, measure completion of all twelve, not the average completion time of one.

Start with your actual concurrency. Increase it only within the endpoint’s allowed usage. Record the number of attempted and successful requests, HTTP 429 responses, RPC errors, timeouts, and how long the complete work unit took. Do not retry inside the timed attempt without reporting it. A successful retry is still a delayed strategy input.

A fixed-concurrency worker loop slows its own request generation when the server slows down. That is a useful model of a backpressured application, but it can conceal queue growth under a fixed arrival rate. If real work arrives with every head regardless of whether the previous head finished, record dropped or queued work too. The queue is part of the bot’s latency.

Race head notifications on one clock

For a feed comparison, open newHeads subscriptions from the same process and record first arrival by block hash, not just height. For each hash, subtract the earliest observed arrival from every other feed’s arrival. This yields relative delivery delay at that vantage point.

A zero means first among the feeds you observed. It does not mean zero network delay. Two hashes at the same height need separate records because a reorg is not a duplicate notification.

Keep a coverage table alongside the delay distribution. State whether its denominator is the union of observed heads or a separately reconciled canonical block range. The intersection of feeds is useful for like-for-like timing, but it hides events the slow or broken feed never delivered. Count those missing events separately. Geth’s subscription documentation explicitly warns that notifications are current events, not historical replay, and that subscriptions disappear when their connection closes.

The same approach can compare first-seen pending transaction hashes, but it cannot establish coverage of a global mempool. Private transactions and different peer views make that denominator unavailable. Our missing pending transactions guide explains the diagnostic boundaries.

Keep submission separate from inclusion

Do not benchmark eth_sendRawTransaction by broadcasting real trades repeatedly. Use a controlled test network for transaction lifecycle tests. In production, instrument submissions your strategy already intends to make, without adding transactions merely to collect timing samples.

For each intended submission, record the signed transaction hash, target block if applicable, send time, response time and recipient. Then reconcile canonical receipts and actual execution results. A relay returning a bundle hash acknowledges a request; it does not reserve block space. The bundle inclusion checklist covers why a successful simulation can still miss its target.

Publish the conditions with the numbers

A useful report names the date, region, host, runtime version, plan tier, transport, connection policy, method, call input, block selection, concurrency and timeout. Include raw observations with secrets removed. State which metrics describe successful responses and show the failure denominator beside them.

For BLAZED.sh, measure from inside the container or script that runs beside the Ethereum mainnet node. The local endpoint is ws://eth:8545, so use a WebSocket measurement for that path; the HTTP-only program above cannot measure it. A tunnel from your laptop is a different path and needs its own label. Co-location removes the external application-to-node hop. It does not guarantee that the node learns every block first or that a builder accepts your trade.

Use the existing measured comparison as a worked report, and the deployment docs to put the real workload on the host. The deciding number is how often the complete strategy finishes on usable state before its deadline. Keep the small RPC timings because they help explain that result, not because they replace it.