JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

Talking to the chain: JSON-RPC, node providers, and indexers

Your app can't curl "the blockchain" — it talks to one node, and every node speaks exactly one language: JSON-RPC. Here is that interface up close, why almost everyone rents a node instead of running one, and why the hard reads — history, totals, top-100s — need a second machine entirely: an indexer like The Graph.

The chain has no front desk

You click Send in a wallet, or open a dApp that greets you with "Balance: 500 ETH." It feels like the app called a server somewhere. But there is no Ethereum.com server with a balance for you. Ethereum is tens of thousands of peer-to-peer nodes, each holding its own copy of the world state. To learn anything, your app has to ask one of those nodes — and every node exposes the same single doorway to ask through: a JSON-RPC interface.

An app needs that doorway for two very different jobs. Reading — what's my balance, did my transaction confirm, what did this contract emit last week — and writing — broadcast my signed transaction so it can change the chain. Both travel over the same JSON-RPC, but they behave nothing alike: reads are free and answer instantly off one node's local state, while writes cost gas and only finish once a block includes them. This guide opens that doorway, explains why most teams rent it from a node provider instead of running their own, and shows why the genuinely hard reads need a whole second machine: an indexer.

JSON-RPC: the chain's one and only API

JSON-RPC is a tiny convention: you POST a JSON object — `{ jsonrpc, method, params, id }` — and get back `{ jsonrpc, id, result }` (or an `error`). Method names are namespaced, and nearly everything an app ever does is one of about forty `eth_` methods. Four of them carry most of the weight, and they map cleanly onto read versus write.

  1. eth_getBalance(address, block) — returns an account's balance in wei, read straight out of the world state. The simplest read there is.
  2. eth_call(tx, block) — SIMULATES a contract call against the state at `block`. The node runs the EVM locally, hands you the function's return value, and changes nothing: no gas, no transaction, no block. This is how you read a contract, e.g. an ERC-20 balanceOf.
  3. eth_sendRawTransaction(signedTx) — the one and only WRITE. You hand the node an already-signed transaction; it validates it and gossips it into the mempool, returning just the transaction hash. The chain hasn't changed yet — inclusion comes later.
  4. eth_getLogs(filter) — fetch past event logs that match a filter (address, topics, block range). This is the only window onto history that raw RPC offers — and, as the next section shows, it strains the moment you ask a real question.

Two details that trip up newcomers. First, every quantity is a hex string in wei — balances, gas, block numbers — so `0x1b1ae4d6e2ef500000` is 500 ETH, not a small number. Second, reads take a block tag: `latest`, `safe`, `finalized`, or a specific height. `eth_call` defaults to `latest` (the current state); asking it for a past height — "what was this balance a year ago?" — only works against an archive node, a distinction that matters a lot in the next sections.

# READ a plain balance — straight from the world state. No gas, no wallet, instant.
curl https://eth-mainnet.example/v2/$KEY -s -X POST \
  -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"eth_getBalance",
       "params":["0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045","latest"]}'
# -> {"jsonrpc":"2.0","id":1,"result":"0x1b1ae4d6e2ef500000"}
#    Quantities are hex, in wei: 0x1b1ae4d6e2ef500000 = 500.0 ETH.

# READ a contract — eth_call SIMULATES balanceOf(addr) on this one node's state.
#   data = 4-byte selector 0x70a08231 (= keccak256("balanceOf(address)")[:4])
#          followed by the 32-byte left-padded address argument.
curl ... -X POST -d '{"jsonrpc":"2.0","id":2,"method":"eth_call","params":[
  {"to":"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
   "data":"0x70a08231000000000000000000000000d8da6bf26964af9d7eed9e03e53415d37aa96045"},
  "latest"]}'
# -> {"jsonrpc":"2.0","id":2,"result":"0x0000000000000000000000000000000000000000000000000000000005f5e100"}
#    0x05f5e100 = 100000000 = 100.000000 USDC (6 decimals). Nothing was mined; no gas.

# WRITE — the ONLY state-changing call. You sign locally; the node merely relays.
curl ... -X POST -d '{"jsonrpc":"2.0","id":3,"method":"eth_sendRawTransaction",
  "params":["0x02f8b201808459682f00...<a fully signed EIP-1559 transaction>...c0"]}'
# -> {"jsonrpc":"2.0","id":3,"result":"0x9f2e6b...c1"}   // returns ONLY the tx hash
#    Inclusion is NOT confirmed yet — poll eth_getTransactionReceipt(hash) for that.
The same JSON-RPC door, used three ways: two reads that return data now (eth_getBalance, eth_call) and the one write that returns only a hash and a promise (eth_sendRawTransaction).

The gateway problem: run your own node, or rent one

Look back at every call above and notice what they silently assume: a node. To make even one `eth_call`, you need a machine running an execution client, fully synced, holding the live world state. Running your own is the trustless ideal — you verify every answer against rules you re-execute yourself — and it is the root of not having to trust anyone. But the node guide was honest about the price: ~1 TB of fast NVMe and days of initial sync for a full node, and an archive node (the only kind that can answer historical `eth_call`) balloons into many terabytes. Few app teams want to babysit that around the clock at the uptime a live product demands.

So a market grew to do it for you: node providers — Infura, Alchemy, QuickNode, Ankr — who run fleets of synced nodes and hand you an HTTPS URL with an API key. Your dApp points its JSON-RPC calls at that URL and never touches a server: archive access, WebSocket subscriptions, generous rate limits, all rented by the month. This is how the overwhelming majority of dApps and wallets actually read and write the chain — including MetaMask, which historically defaulted to Infura.

The read problem: why raw RPC can't answer real questions

`eth_getBalance` and `eth_call` are superb at exactly one thing: the current value of one slot you can name. Real apps ask harder questions. *Show every token this wallet has ever received. What were today's ten largest swaps in this pool? Who are the top 100 holders? Plot my balance over the past year.* None of these is a single named slot — they are queries over history and across many accounts — and the JSON-RPC API simply has no method for any of them.

The only historical primitive RPC gives you is `eth_getLogs`, which scans event logs — and events are the chain's deliberate "changelog," since a contract emits a log for every change worth watching (an ERC-20 `Transfer(from, to, value)`, a swap, a mint). But `eth_getLogs` is a blunt instrument:

  1. It scans by block range, and providers cap that range hard — often a few thousand blocks, or ~10,000 results, per call. So "from genesis to now" means HUNDREDS of paginated, rate-limited requests stitched together by hand.
  2. It returns raw, undecoded logs — indexed topics plus an opaque data blob you must ABI-decode yourself. The node will not join, sort, sum, or filter by a non-indexed field for you.
  3. Aggregation is impossible server-side. "Current balance of every holder" means replaying EVERY Transfer log since deployment and totalling them in your own code.
  4. Point-in-time state — "balance at block 15,000,000" — needs eth_call with a past block tag, which only an archive node can serve, and even then one account at a time.

Follow that to its conclusion and you'll end up writing a script that pulls millions of logs, decodes them, and stuffs them into your own database so you can finally query them. At that exact moment you have discovered you need an indexer. The job — ingest the chain's events, transform them, and store them so they're queryable — turns out to be universal enough that it became its own layer of infrastructure rather than something every team rebuilds badly.

Indexers and The Graph: a queryable mirror of the chain

An indexer is a service that watches the chain, processes each new block's events as they arrive, and writes the results into an ordinary database that speaks a rich query language. The dominant standard on Ethereum is The Graph, where one indexer definition is called a subgraph. A subgraph is really just three files:

  1. The manifest (subgraph.yaml): which contract address, which ABI, which block to start indexing from, and which events to listen for — wiring each event to a handler function.
  2. The schema (schema.graphql): the entities you want to store and query — say a Holder with an address and balance, and a Transfer with from / to / amount / block. This IS your GraphQL API.
  3. The mappings (mapping.ts, in AssemblyScript): handler functions that turn a raw event into entities — handleTransfer loads the two holders, adjusts their balances, and saves a Transfer record.

Deploy it, and the indexer (graph-node) syncs from the start block forward, replaying every matching event through your mappings and building the entity tables row by row. Crucially it remembers each block's hash, so it can handle re-orgs: if a block it already indexed gets orphaned, it rolls those entity changes back rather than serving stale data. The finished store is exposed as a GraphQL endpoint — and now "top 100 holders" or "this wallet's transfers, newest first, page 2" is a single query with `where`, `orderBy`, and pagination: precisely the things raw RPC could never do.

// schema.graphql — the shape of your queryable data == your GraphQL API
type Holder @entity {
  id: ID!                 // the holder's address
  balance: BigInt!
  received: [Transfer!]! @derivedFrom(field: "to")
}
type Transfer @entity(immutable: true) {
  id: ID!                 // txHash-logIndex, globally unique
  from: Holder!
  to: Holder!
  amount: BigInt!
  block: BigInt!
}

// mapping.ts (AssemblyScript) — runs ONCE per Transfer log the chain emits
export function handleTransfer(ev: TransferEvent): void {
  let to = Holder.load(ev.params.to.toHex())
  if (to == null) { to = new Holder(ev.params.to.toHex()); to.balance = BigInt.zero() }
  to.balance = to.balance.plus(ev.params.value)
  to.save()                       // ...and symmetrically subtract from ev.params.from
  let t = new Transfer(ev.transaction.hash.toHex() + "-" + ev.logIndex.toString())
  t.from = ev.params.from.toHex()
  t.to = ev.params.to.toHex()
  t.amount = ev.params.value
  t.block = ev.block.number
  t.save()
}

# A query the frontend runs — impossible as a single eth_getLogs call:
query {
  holders(first: 100, orderBy: balance, orderDirection: desc) { id balance }
}
One subgraph in miniature: a schema (what to store), a mapping (how each Transfer event updates it), and the GraphQL query a UI actually runs — sorted and paginated server-side.

The full read path: from a contract event to the screen

Put the two halves together and you can trace any piece of data in a dApp from the chain all the way to the user's eyes. A write goes out through `eth_sendRawTransaction`; the interesting reads come back along a longer road, because they no longer come from the node at all — they come from the indexer.

  1. A user's transaction executes inside a smart contract; partway through, the contract runs emit Transfer(from, to, amount), writing a log into the transaction receipt and the block.
  2. The indexer (graph-node) — connected to an execution node over JSON-RPC and subscribed to that contract — sees the new block, pulls the matching logs, and runs handleTransfer, updating the Holder and Transfer entities in its store.
  3. The frontend, instead of hammering eth_getLogs in a loop, sends one GraphQL query to the subgraph: "transfers to me, newest first, page 1."
  4. The indexer answers from its database in milliseconds — already decoded, sorted, and paginated — and the UI renders the list.
  5. For LIVE single values the app still hits JSON-RPC directly: eth_getBalance for the current ETH balance, eth_call for a current on-chain price — because those are single, current slots a node answers instantly.

That division of labor is the quiet architecture under almost every dApp: JSON-RPC for writes and simple current reads; an indexer for history and aggregation. The wallet signs, the provider relays, the contract executes, the indexer remembers, the frontend asks in GraphQL. The capstone guide wires all of it — wallet, RPC, contract, indexer, frontend — into a single user click; here you've built the two pipes that carry every byte between an app and the chain.