Backend for Web3
"Why do I need a backend if everything's on-chain?" Because querying a chain for "all Transfers where to = X, grouped by day" through eth_getLogs is brutal. You need an indexer.
1. The classic Web3 architecture
Chain is your event log (append-only, source of truth). Your SQL DB is the materialized view. Exactly like CQRS / Kafka-Connect-to-Postgres.
2. Why on-chain queries don't scale
eth_getLogshas per-node caps (Alchemy = 10k results, 2k blocks per call typically).- No joins, no aggregations.
- Re-scanning billions of blocks per request is not free — you're a bad neighbor.
- L2s emit millions of events per day. You need a DB.
3. A minimal indexer in ~80 lines
// npm i ethers pg
const { JsonRpcProvider, Contract, Interface } = require('ethers');
const { Pool } = require('pg');
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const provider = new JsonRpcProvider(process.env.RPC_URL);
const iface = new Interface([
"event Transfer(address indexed from, address indexed to, uint256 value)"
]);
const TOKEN = "0x...";
async function getCursor() {
const r = await pool.query("SELECT last_block FROM cursor WHERE id='tok'");
return r.rows[0]?.last_block ?? DEPLOY_BLOCK;
}
async function setCursor(b) {
await pool.query(
"INSERT INTO cursor(id,last_block) VALUES('tok',$1) ON CONFLICT (id) DO UPDATE SET last_block=$1", [b]);
}
async function tick() {
const head = await provider.getBlockNumber();
const safe = head - 5; // reorg buffer
let from = await getCursor() + 1;
while (from <= safe) {
const to = Math.min(from + 1999, safe);
const logs = await provider.getLogs({
address: TOKEN, fromBlock: from, toBlock: to,
topics: [iface.getEvent("Transfer").topicHash]
});
for (const l of logs) {
const { args } = iface.parseLog(l);
await pool.query(
`INSERT INTO transfers(tx_hash, log_idx, block, "from", "to", value)
VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT DO NOTHING`,
[l.transactionHash, l.index, l.blockNumber,
args.from, args.to, args.value.toString()]);
}
await setCursor(to);
from = to + 1;
}
}
setInterval(tick, 12_000);
(tx_hash, log_idx) for idempotency.4. Reorgs — the tricky part
Blocks <~12 confirmations can disappear. Your indexer must either (a) stay a few blocks behind the head, or (b) store block hashes and roll back if a hash changes.
-- Simple approach: track block_hash; on tick, refetch last N blocks and compare
SELECT block, hash FROM ingested_blocks ORDER BY block DESC LIMIT 12;
5. The Graph — indexing as a service
Instead of rolling your own, you can write a subgraph: a YAML + AssemblyScript handler that The Graph's nodes execute. You query with GraphQL. Great for public, read-heavy apps.
# schema.graphql
type Transfer @entity {
id: ID!
from: Bytes!
to: Bytes!
value: BigInt!
block: BigInt!
}
# mapping.ts
export function handleTransfer(e: Transfer): void {
let t = new Transfer(e.transaction.hash.toHex() + "-" + e.logIndex.toString())
t.from = e.params.from
t.to = e.params.to
t.value = e.params.value
t.block = e.block.number
t.save()
}
6. When to use The Graph vs roll your own
| Use The Graph | Roll your own |
|---|---|
| Public data, read-heavy | Need auth, private data, custom joins |
| Ethereum / major L2s | Exotic chain or low-latency needs |
| OK with GraphQL | Need fine-grained SQL & analytics |
| Want minimal ops | Already have DB + infra |
7. RPC providers & rate limits
Alchemy, Infura, Ankr, QuickNode. Free tiers are ~300 req/s, plenty for indexers if you batch. Use eth_getLogs with the widest block window the provider allows; use alchemy_getAssetTransfers for one-shot backfills.
8. Websocket subscriptions
import { WebSocketProvider } from 'ethers';
const p = new WebSocketProvider(process.env.WSS_URL);
p.on('block', (n) => console.log('new block', n));
contract.on('Transfer', (from, to, val) => push(...));
Use WS for hot listeners, HTTP polling as a fallback. WS disconnects all the time — wrap with auto-reconnect.
9. Transaction-submission backend (relayers)
Sometimes the backend is also a signer: it holds a hot key to submit txs on behalf of users (gasless UX, batching). Patterns:
- Meta-transactions (EIP-2771) — user signs a typed message; relayer submits.
- Account abstraction (ERC-4337) — user-operations handled by "bundlers". Gaining traction in 2025–26.
- Use a KMS (AWS KMS / Google Cloud KMS) for the hot key. Never
.envin production.
10. Project
GET /holders?limit=50 computing top holders from the transfers table. Ship with health checks and a README.Quiz
- Delete the entire table nightly
- Only index blocks < head − N (N≈12 for L1, ~30 for some L2s) and/or store block hashes to detect reorgs
- Add an API Gateway retry
- Switch to a faster RPC