Four fields out, a result or an error back. The protocol every wallet, indexer, and dApp is built on, and the places where it quietly stops being reliable.
Every wallet balance you display, every transaction you broadcast, every event your indexer processes started life as a JSON-RPC request. It is the wire protocol between your application and a blockchain node: your app sends a small JSON object naming a method, the node sends a JSON object back with the result. That is the whole idea. There is no SDK magic underneath viem or ethers: those libraries are, at bottom, JSON-RPC request factories.
This article walks through the protocol concretely: the request and response shape, the Ethereum methods you will actually use, how other chains differ, batching and subscriptions, and, because the spec is the easy part, the places where JSON-RPC gets unreliable in production.
A note on scope. The examples below are Ethereum and EVM-flavored —
eth_*methods, hex-encoded quantities, the JSON-RPC 2.0 envelope Ethereum standardized on. That is where most readers meet JSON-RPC first, and it is the richest method set to explain against. But Smart Router is not EVM-only: it routes RPC traffic for Solana, Bitcoin, Cosmos chains, Tron, and other non-EVM protocols too. The framing of the protocol — request/response envelopes, batching, subscriptions, and especially the reliability failure modes covered near the end (provider drift, silent stale reads, rate limits, failover) — carries over to those ecosystems almost unchanged. The method names and encodings differ; the operational problems do not.
What is JSON-RPC?
JSON-RPC is a lightweight remote procedure call protocol, specified independently of blockchains back in the 2000s. "Remote procedure call" means exactly what it says: your program asks a program on another machine to run a named procedure with some arguments and return the result, as if it were a local function call. JSON-RPC encodes that exchange as JSON, and it is transport-agnostic: the same payload works over HTTP or a WebSocket.
Ethereum adopted JSON-RPC 2.0 as the standard interface to its nodes, and the EVM ecosystem followed, which is why the same request format works against Ethereum, the L2s, and every EVM-compatible chain.
A request has four fields:
{
"jsonrpc": "2.0",
"method": "eth_blockNumber",
"params": [],
"id": 1
}
jsonrpc is the protocol version, always "2.0". method is the name of the procedure you want the node to run. params is a positional array of arguments (empty here, because asking for the latest block number needs none). id is a value you choose; the node echoes it back so you can match responses to requests, which is essential once you send requests concurrently or in batches.
Send it with curl to any Ethereum endpoint, in this case a locally running router on port 3360, per the quickstart:
curl -X POST http://127.0.0.1:3360 \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}'
And the response:
{
"jsonrpc": "2.0",
"id": 1,
"result": "0x1836f5a"
}
The result is the latest block number the node knows about, as a hex-encoded quantity (Ethereum's JSON-RPC encodes all numbers this way). On failure, result is replaced by an error object with a numeric code and message:
{
"jsonrpc": "2.0",
"id": 1,
"error": { "code": -32601, "message": "Method not found" }
}
Codes in the -32700 to -32600 range come from the JSON-RPC spec itself (parse errors, invalid requests, unknown methods); anything else is defined by the node or chain. Note the HTTP status is usually 200 OK either way; the error lives inside the JSON body, which trips up naive monitoring that only watches HTTP codes.
That is the entire protocol. Everything else is a question of which methods exist, and those are defined per chain.
The eth JSON-RPC methods you'll actually use
Ethereum nodes expose dozens of methods, but a handful carry almost all real traffic:
| Method | What it does | Read or write? |
|---|---|---|
eth_blockNumber | Returns the latest block number the node has | Read |
eth_call | Executes a contract call without creating a transaction | Read |
eth_getLogs | Returns event logs matching a filter (address, topics, block range) | Read |
eth_sendRawTransaction | Broadcasts a signed transaction to the network | Write |
The read/write distinction matters more than it looks. Reads (eth_blockNumber, eth_call, eth_getBalance, eth_getLogs, and most everything else) execute locally on the node against its copy of the chain. They cost nothing on-chain, change no state, and are safe to retry.
Writes, effectively just eth_sendRawTransaction, are different. The node doesn't sign anything and can't spend your funds; your app signs the transaction locally and hands the node opaque signed bytes to broadcast. But a write mutates network state, costs gas, and a success response only means "accepted into the mempool," not "executed." You still have to poll eth_getTransactionReceipt to learn what happened. Retry semantics differ too: rebroadcasting the same signed bytes is harmless (same hash, the network dedupes it), but signing and sending again is a new transaction.
Two of the reads deserve a closer look, because they hide sharp edges:
eth_call runs contract code ("what would this function return right now?") and takes a block parameter. Against a fixed block number, the result is deterministic: every honest, synced node returns the same bytes. Against "pending", it is non-deterministic by definition, because every node has its own view of the mempool.
eth_getLogs is how indexers, bridges, and monitoring systems consume events, and it's typically the heaviest read a node serves. It is also, in practice, the method nodes most visibly disagree on; more on that below.
A copy-pasteable example of each, ready to run against the same endpoint:
# eth_call: read a contract (here, an ERC-20 totalSupply()) at the latest block
curl -X POST http://127.0.0.1:3360 -H 'Content-Type: application/json' -d '{
"jsonrpc": "2.0",
"method": "eth_call",
"params": [{
"to": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
"data": "0x18160ddd"
}, "latest"],
"id": 2
}'
# eth_getLogs: all Transfer events from one contract over a block range
curl -X POST http://127.0.0.1:3360 -H 'Content-Type: application/json' -d '{
"jsonrpc": "2.0",
"method": "eth_getLogs",
"params": [{
"address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
"topics": ["0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"],
"fromBlock": "0x1836000",
"toBlock": "0x1836f5a"
}],
"id": 3
}'
For a precise definition of every term in this section (endpoint, upstream, finality, chain head), see the RPC reliability glossary.
Not every chain speaks JSON-RPC the same way
It's tempting to file "JSON-RPC" as the blockchain protocol. It isn't. Each ecosystem picked its own interface, and a multi-chain app ends up speaking several:
- Ethereum and the EVM world speak JSON-RPC 2.0 with the
eth_*method namespace, as above. - Solana also uses JSON-RPC 2.0, but with an entirely different method set (
getSlot,getAccountInfo,getSignatureStatuses) and different semantics: same envelope, different language. - Cosmos chains expose three interfaces at once: a REST API (plain GET/POST paths like
/cosmos/base/tendermint/v1beta1/blocks/latest), gRPC for typed high-throughput queries, and Tendermint RPC for consensus-layer data. None of them is Ethereum-style JSON-RPC. - Bitcoin uses an older JSON-RPC 1.0-style interface with its own methods (
getblockcount,getrawtransaction).
This is visible in how infrastructure handles it. Smart Router's connection docs put it plainly: your client speaks the same protocol it would speak to any other RPC endpoint (JSON-RPC for Ethereum, REST or gRPC for Cosmos, Tendermint RPC for Tendermint chains), with one listener per chain-and-interface pair. The chain catalog describes each of 75+ chains with a spec declaring exactly which API interfaces it exposes. Same Cosmos data, three protocols:
# Cosmos REST
curl http://127.0.0.1:3360/cosmos/base/tendermint/v1beta1/blocks/latest
# Cosmos gRPC
grpcurl -plaintext 127.0.0.1:3361 cosmos.bank.v1beta1.Query/TotalSupply
The practical takeaway: "JSON-RPC" describes Ethereum's dialect, not blockchain APIs in general. If your roadmap includes non-EVM chains, your RPC layer needs to handle multiple protocols, not just multiple endpoints.
Batching and subscriptions, briefly
Two extensions to the basic request/response loop are worth knowing.
Batching. JSON-RPC 2.0 lets you send an array of request objects in one HTTP round trip; the node returns an array of responses, matched by id (order is not guaranteed; that's what id is for):
[
{ "jsonrpc": "2.0", "method": "eth_blockNumber", "params": [], "id": 1 },
{ "jsonrpc": "2.0", "method": "eth_chainId", "params": [], "id": 2 }
]
Batching cuts network overhead for bulk reads, but note that many providers count each element against rate limits and cap batch sizes, so a batch is cheaper in round trips, not in quota.
WebSocket subscriptions. Over a WebSocket connection, eth_subscribe inverts the flow: instead of polling, you subscribe to newHeads (new blocks) or logs (matching events) and the node pushes notifications as they happen. Useful for anything latency-sensitive; the caveat is that subscriptions are stateful: when the socket drops, you must reconnect, resubscribe, and backfill whatever you missed, which is exactly the kind of edge-case code that fails quietly.
Where JSON-RPC gets unreliable in practice
The spec is clean. Production is not. The protocol defines the shape of an answer, but says nothing about whether the answer is current, consistent, or even correct, and different nodes routinely give different answers to the same question. Three examples every infrastructure team eventually meets, drawn from the cross-validation docs' when-to-use table:
Nodes disagree on eth_getLogs. Nodes commonly disagree on log ordering for the same query, enough that the docs' guidance is to cross-validate eth_getLogs results whenever downstream code depends on them. If your indexer's correctness assumes a stable ordering from a single node, that assumption will eventually break.
eth_blockNumber differs by design. Two healthy nodes will naturally sit a block apart at the chain tip, simply because blocks propagate at different times. Comparing their answers isn't a health check, it's noise, which is why the same table marks latest-block reads as not worth cross-validating.
Pending calls are non-deterministic. An eth_call against the pending block depends on the node's local mempool view. Two nodes giving different answers are both "right." Any logic that treats a pending read as a fact is built on sand; the same is true of pinning behavior to whatever "latest" happens to mean on the node that answered.
The pattern across all three: the same method can be deterministic or non-deterministic depending on its parameters, and knowing which is which determines how you should handle disagreement. Fixed-block eth_call results are easy to compare across nodes and worth verifying for critical reads; pending and chain-tip reads will never agree exactly and shouldn't be forced to.
This is why treating the node's answer as automatically true is a mistake, a lesson that extends beyond bugs into security territory, because a compromised or malicious endpoint speaks perfectly valid JSON-RPC while lying in the result field. Nothing in the protocol authenticates the content of a response. If a wrong answer would cost you money, the fix is structural: query several independent nodes and require agreement, rather than trusting whichever one answered first. That, plus the risk of depending on a single provider, is the core argument for putting a routing and validation layer between your app and your endpoints.
Pointing a real client at an endpoint
You will rarely hand-write JSON-RPC payloads outside of debugging. Libraries wrap the protocol; you hand them an endpoint URL. The snippets below are from the Smart Router connection docs and work against any Ethereum JSON-RPC endpoint; the point of a routing layer is precisely that clients don't need to know it's there:
// viem
import { createPublicClient, http } from 'viem';
import { mainnet } from 'viem/chains';
const client = createPublicClient({
chain: mainnet,
transport: http('http://127.0.0.1:3360'),
});
// ethers v6
import { JsonRpcProvider } from 'ethers';
const provider = new JsonRpcProvider('http://127.0.0.1:3360');
# web3.py
from web3 import Web3
w3 = Web3(Web3.HTTPProvider('http://127.0.0.1:3360'))
When client.getBlockNumber() or w3.eth.block_number runs, what crosses the wire is exactly the eth_blockNumber payload from the top of this article. The library is convenience; JSON-RPC is the contract.
JSON-RPC is simple enough to learn in an afternoon: four fields out, result or error back. What isn't simple is operating on top of it: multiple chains speaking different dialects, nodes that disagree, and responses whose correctness the protocol never guarantees. Smart Router is an open-source layer that handles that part: your app speaks ordinary JSON-RPC to one endpoint, and routing, failover, and cross-validation happen behind it. Run it in three commands, or talk to the team about a production deployment.
Sources: JSON-RPC 2.0 Specification; ethereum.org, JSON-RPC API; Smart Router documentation.
How exposed is your RPC stack?
Take the 2-minute Secure RPC Assessment and get a personalized risk report.
Run the assessment

