This article explains how to measure and optimize Monad RPC latency. It covers Monad's unique architecture (parallel execution, deferred execution, sub-second blocks) and how it affects RPC behavior. You'll get a reproducible measurement method using curl and viem, plus optimization strategies like batching, caching, and WebSocket subscriptions.
Direct Answer: What Determines Monad RPC Latency?
Monad RPC latency is the time between sending a JSON-RPC request and receiving a response. It is dominated by three factors: network distance to the endpoint, the endpoint's load (especially shared public endpoints), and the weight of the specific RPC method. On Monad, the sub-second block time and deferred execution model add a unique twist: block-head freshness and state availability can vary depending on how the node processes transactions. To get low latency, you need a geographically close, dedicated endpoint and you must design your queries to be lightweight and cacheable.
This article is a practical guide to measuring and optimizing Monad RPC latency. It is not a vendor benchmark; all numbers you see in the results table are meant to be reproduced by you in your own environment. We'll cover Monad's architecture, a step-by-step measurement method, common bottlenecks, and optimization techniques.
- Network distance: The physical distance between your client and the RPC endpoint adds round-trip time (RTT).
- Endpoint saturation: Public endpoints are shared; heavy usage by others can cause queueing and rate limiting.
- Method weight: eth_getLogs over a large block range is far heavier than eth_chainId.
- Monad's deferred execution: Blocks are proposed quickly, but state may not be immediately available for all transactions until execution catches up.
- WebSocket vs polling: On a fast chain, polling eth_blockNumber every 400ms can miss blocks; subscriptions are more efficient.
Monad's Architecture and Its Impact on RPC
Monad is an EVM-compatible Layer 1 that uses parallel optimistic execution and deferred execution to achieve high throughput (design target of 10,000 TPS) with sub-second block times (around 1 second or less). MonadBFT consensus enables fast finality. For RPC clients, this means that new blocks arrive frequently, and the state may be updated optimistically before all transactions are fully executed.
The Monad documentation explains that while blocks are produced quickly, the execution of transactions can be deferred. This means that when you query eth_blockNumber, you might get the latest block header, but eth_getBalance or eth_call on that block might not reflect all transactions if execution hasn't completed. This is a documented behavior, not a bug. For most use cases, the delay is minimal, but for latency-sensitive applications, you should be aware of it.
Another consequence of sub-second blocks is that polling for new blocks every 1 second might miss blocks. WebSocket subscriptions (eth_subscribe) are the recommended way to get real-time newHeads and logs. Monad's docs also note that eth_getLogs can be expensive if you query a wide block range, so you should paginate by block window.
- Monad's parallel execution allows multiple transactions to be processed simultaneously, but RPC state reads may see a consistent snapshot.
- Deferred execution means that a block's state might not be fully available immediately after the block is proposed.
- Sub-second block times make polling inefficient; use WebSocket subscriptions for real-time data.
- eth_getLogs is a heavy method; always limit the block range and use pagination.
Reproducible Measurement Method
To measure Monad RPC latency accurately, you need to separate network latency from server processing time. The following method uses curl for a quick check and a Node.js script with viem for more detailed statistics. All measurements are to be run by you; we provide the code and a results table to fill in.
Assumptions: You have Node.js 18+ installed, and you have access to a Monad RPC endpoint (public or dedicated). The test date is 2026-09-02. We recommend running the test from a machine that is geographically close to your target endpoint to minimize network noise. Run each test multiple times (e.g., 10 iterations) and compute the median (p50) and 95th percentile (p95).
- Use curl -w to measure total time and breakdown (DNS, connect, TTFB, total).
- Use a viem script to send sequential and concurrent requests to measure p50/p95.
- Record your results in the table below; do not compare with vendor benchmarks.
curl -s -o /dev/null -w "DNS: %{time_namelookup}s\nConnect: %{time_connect}s\nTTFB: %{time_starttransfer}s\nTotal: %{time_total}s\n" \
-X POST https://rpc.monad.xyz -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"eth_chainId","params":[],"id":1}'
# Expected output shape (values will vary):
# DNS: 0.001s
# Connect: 0.020s
# TTFB: 0.050s
# Total: 0.051sNode.js Measurement Script with viem
The following script uses viem to send a series of JSON-RPC requests and measure latency. It tests eth_chainId (cheap), eth_blockNumber (cheap), eth_getBalance (moderate), and eth_getBlockByNumber (moderate). It runs each method sequentially and concurrently to simulate real-world load. The script outputs p50 and p95 latencies in milliseconds.
To run it, save the code as measure-monad-latency.mjs and run with node measure-monad-latency.mjs. You'll need to install viem first: npm install viem.
import { createPublicClient, http } from 'viem';
const RPC_URL = process.env.RPC_URL || 'https://rpc.monad.xyz';
const client = createPublicClient({ transport: http(RPC_URL) });
const methods = {
eth_chainId: () => client.getChainId(),
eth_blockNumber: () => client.getBlockNumber(),
eth_getBalance: () => client.getBalance({ address: '0x0000000000000000000000000000000000000000' }),
eth_getBlockByNumber: () => client.getBlock({ blockNumber: 1n }),
};
async function measure(method, iterations = 10) {
const times = [];
for (let i = 0; i < iterations; i++) {
const start = performance.now();
await method();
times.push(performance.now() - start);
}
times.sort((a, b) => a - b);
const p50 = times[Math.floor(iterations * 0.5)];
const p95 = times[Math.floor(iterations * 0.95)];
return { p50, p95 };
}
async function measureConcurrent(method, iterations = 10) {
const times = [];
const start = performance.now();
await Promise.all(Array.from({ length: iterations }, () => method()));
times.push(performance.now() - start);
return { p50: times[0], p95: times[0] }; // simplified for concurrent
}
console.log('Sequential measurements (ms):');
for (const [name, fn] of Object.entries(methods)) {
const { p50, p95 } = await measure(fn);
console.log(`${name}: p50=${p50.toFixed(2)} p95=${p95.toFixed(2)}`);
}
console.log('\nConcurrent measurements (10 parallel requests, total time ms):');
for (const [name, fn] of Object.entries(methods)) {
const { p50 } = await measureConcurrent(fn);
console.log(`${name}: total=${p50.toFixed(2)}`);
}
// Expected output shape (values will vary):
// Sequential measurements (ms):
// eth_chainId: p50=12.34 p95=15.67
// eth_blockNumber: p50=13.45 p95=16.78
// eth_getBalance: p50=20.11 p95=25.34
// eth_getBlockByNumber: p50=18.22 p95=22.45
//
// Concurrent measurements (10 parallel requests, total time ms):
// eth_chainId: total=45.67
// eth_blockNumber: total=48.90
// eth_getBalance: total=70.12
// eth_getBlockByNumber: total=65.43Results Table (Reader-Run Measurement)
Fill in the table below with your own measurements. This is not a vendor benchmark; it is a method for you to compare endpoints or track performance over time. Use the same RPC URL and run the script multiple times to get stable results.
When comparing endpoints, ensure you run the tests from the same machine and at similar times of day to reduce variance.
- Record the endpoint URL, date, and time of test.
- Run the script at least 3 times and take the median of the p50 values.
- Note any rate limiting (HTTP 429) or timeouts you encounter.
| Method | p50 (ms) | p95 (ms) | Concurrent total (ms) |
|--------|----------|----------|------------------------|
| eth_chainId | | | |
| eth_blockNumber | | | |
| eth_getBalance | | | |
| eth_getBlockByNumber | | | |
(Example row: eth_chainId | 12.3 | 15.6 | 45.7)Common Bottlenecks and How to Fix Them
Even with a fast endpoint, you can hit latency bottlenecks. The most common are: using a public endpoint that is far away, making too many requests (especially heavy ones), and polling for new blocks instead of subscribing. Here are specific fixes.
First, choose an endpoint that is geographically close to your application. If your users are global, use a geo-distributed provider or a dedicated endpoint. OnFinality's API service offers dedicated endpoints with global coverage, but you should evaluate based on your own tests.
Second, batch your JSON-RPC requests. Instead of sending 10 separate eth_getBalance calls, use the batch endpoint to send them in one HTTP request. This reduces round trips and overhead. Many providers support batching; check your provider's documentation.
Third, cache read-only state. If you frequently query the same account balance or contract state, cache it client-side and invalidate on new blocks. This reduces RPC load and latency.
Fourth, when using eth_getLogs, always specify a block range and paginate. For example, query logs in chunks of 1000 blocks. This prevents the node from scanning the entire chain.
Finally, for real-time data, use WebSocket subscriptions (eth_subscribe) instead of polling. Monad's sub-second blocks mean that polling every 1 second will miss blocks. See our Monad WebSocket RPC guide for details.
- Geographic distance: Use a geo-distributed or dedicated endpoint.
- Batching: Combine multiple requests into one HTTP call.
- Caching: Cache balances and state reads client-side.
- eth_getLogs pagination: Limit block range and use pagination.
- WebSocket subscriptions: Use for newHeads and logs instead of polling.
Monad-Specific Considerations: Deferred Execution and Block Freshness
Monad's deferred execution can cause a situation where eth_blockNumber returns a block that is not yet fully executed. This is documented behavior: the block header is available immediately, but state reads (eth_getBalance, eth_call) may reflect the state before some transactions in that block are applied. For most applications, this is not an issue because the delay is milliseconds. However, if you need to read state that depends on the latest transactions, you should wait for the block to be finalized or use a method that ensures execution.
Monad's documentation suggests using eth_getBlockByNumber with the 'pending' tag to get the latest state, but this is not always reliable. The safest approach is to use a dedicated endpoint that is configured to wait for execution, or to add a small delay (e.g., 100ms) after seeing a new block before querying state.
Another consideration is the block time. With sub-second blocks, the eth_blockNumber method will return a new value very frequently. If you are polling, you might get rate limited. Use WebSocket subscriptions to receive newHeads events as they happen, which is more efficient and lower latency.
- Deferred execution: eth_blockNumber may return a block whose state is not yet fully updated.
- To read state after a new block, wait for execution to catch up or use a dedicated endpoint.
- Sub-second blocks make polling inefficient; use subscriptions.
Tradeoffs and Limitations
Optimizing for latency often involves tradeoffs. For example, using a dedicated endpoint costs more than a public one, but gives you consistent performance. Batching requests reduces latency but adds complexity to your code. Caching state reduces RPC load but may serve stale data if not invalidated properly.
There are also limitations to what you can optimize. Network latency is bounded by the speed of light; you cannot reduce it beyond the physical distance. Public endpoints are shared, so you cannot control other users' load. And Monad's deferred execution is a protocol feature; you cannot disable it.
When measuring latency, be aware that your results will vary based on the endpoint, time of day, and network conditions. Always run multiple tests and use percentiles (p50, p95) rather than averages to get a realistic picture.
- Dedicated endpoints cost more but offer consistent performance.
- Batching adds code complexity but reduces round trips.
- Caching can serve stale data if not invalidated on new blocks.
- Network latency is physical; you cannot reduce it beyond distance.
- Public endpoints are shared; you cannot control other users' load.
Next Steps and Further Reading
Now that you know how to measure and optimize Monad RPC latency, you can apply these techniques to your own applications. Start by running the measurement script against your current endpoint, then try the optimizations and measure again. You should see improvements in p95 latency.
For more Monad RPC guidance, check out our other articles: Monad RPC endpoints (RPC Assistant) for endpoint selection, Monad RPC timeout and retries for handling timeouts, and Monad RPC rate limits and 429s for avoiding rate limits. Also see the Monad WebSocket RPC guide for real-time data.
If you're building on Monad, you might also want to explore the Monad mainnet network page for network details, and the OnFinality Learn hub for more tutorials. For pricing on dedicated endpoints, see RPC pricing.
- Run the measurement script against your endpoint and record results.
- Implement batching, caching, and WebSocket subscriptions.
- Re-measure to see improvements.
- Explore related guides for timeouts, rate limits, and WebSocket.