At RootstockLabs, one of the products I owned the architecture for was the RootstockCollective dApp (frontend source) — a UI that needs to answer questions like “what’s this user’s balance,” “what has this contract emitted since block X,” and “show me the history of this account” on every page load. This is the story of how that UI went from asking the chain those questions directly, every time, to asking a decentralized indexing network instead — and why the process mattered as much as the destination.
Where the time was actually going#
The first version worked the obvious way: the React app called the chain directly — eth_call for current state, eth_getLogs for history — and did the rest itself. Reconstructing a user’s position, deriving history, combining data from several contracts: all of that logic lived on the client, in the React app’s own state layer. It re-fetched and re-derived the same things on every view, every user, every refresh. That’s a perfectly reasonable way to ship a first version — it only becomes a problem once real traffic exposes what scales and what doesn’t.
graph TD
U[User] --> UI["React App
(client-side aggregation)"]
UI -->|"eth_call (state) +
eth_getLogs (re-scan block range)"| RPC[RPC Node]
RPC --> Chain[(Rootstock Chain)]
UI -->|"derive balances,
reconstruct history,
join across contracts"| Store["Client-side State"]
Store --> UI
Before writing a single line of indexing code, the first step was just watching where that fell over — pulling call volumes from the RPC provider’s dashboard and attributing them per page and per call type, rather than guessing: which calls were repeated on nearly every page, which ones scaled with the number of users rather than staying flat, and which “derive this from raw events” logic in the React store was really doing database work by hand, in the browser, on every load. Log-range rescans for account history and repeated recomputation of positions across contracts were the two that dominated everything else.
Designing the data model first#
The instinct once you’ve found the expensive calls is to go straight to indexing them. The more useful step in between was designing the data model those calls actually needed — before writing any indexing logic. That meant working backward from the UI’s real questions (“this user’s current position across several contracts,” “this account’s activity over time”) rather than forward from the contracts’ storage layout. A schema that just mirrors Solidity structs one-to-one still leaves the application joining and deriving data itself; a schema shaped around the actual queries doesn’t.
The actual entities are specific to this dApp’s contracts, but the shape that came out of that exercise looked roughly like this — accounts holding positions across several contracts, with a single activity timeline joining events from all of them, instead of one table per contract mirroring its storage layout one-to-one:
erDiagram
ACCOUNT ||--o{ POSITION : holds
ACCOUNT ||--o{ ACTIVITY_EVENT : "has history of"
POSITION }o--|| CONTRACT : "tracked across"
ACTIVITY_EVENT }o--|| CONTRACT : "emitted by"
A per-contract mirror of Solidity storage would have forced the client back into the same joins and derivations it had before — this shape lets “this user’s position” and “this account’s history” be single queries instead.
Indexing with The Graph, plus a sync layer on top#
With the data model settled, the indexing side ended up as two layers, not one. Raw contract events are indexed by a subgraph on The Graph: it declares which events to watch and how to map each one into entities matching that schema, queryable over GraphQL. On top of that sits a purpose-built state-sync service — a Node.js/TypeScript service that continuously syncs the subgraph’s data (plus a few things watched directly from the chain that the subgraph doesn’t cover, and a couple of external data sources) into its own PostgreSQL database, with tables shaped for exactly what the frontend needs to query.
The choice to index via The Graph rather than a self-hosted indexer wasn’t only about not wanting to run and operate that piece of infrastructure ourselves. The Graph is a decentralized protocol: the raw event-indexing work is done by a network of independent Indexers who stake to back the service they provide, rather than by a single node or provider one team depends on. For a dApp built on the premise that the chain itself isn’t controlled by any single party, indexing that chain’s events through infrastructure we alone operated would have quietly reintroduced exactly the kind of single point of control the rest of the architecture was designed to avoid.
The sync service on top is something we do operate, so this isn’t decentralized end to end — that’s a deliberate, narrower trade rather than an inconsistency. The part that has to watch and interpret raw chain events at scale is backed by a decentralized network; the service sitting above it is a thin, replaceable layer that combines already-indexed data with a few other sources and reshapes it for the frontend — not the thing doing the indexing itself.
graph TD
U[User] --> UI["React App
(thin query layer)"]
UI -->|"query"| Sync["State-Sync Service
(Node.js + PostgreSQL)"]
Sync -->|"sync subgraph data"| TG["The Graph
(decentralized network)"]
subgraph TG_Network [" "]
I1[Indexer]
I2[Indexer]
I3[Indexer]
end
TG --- I1
TG --- I2
TG --- I3
I1 -->|watches events| RPC[RPC Node]
I2 -->|watches events| RPC
I3 -->|watches events| RPC
Sync -->|"watches a few events
directly + external sources"| RPC
RPC --> Chain[(Rootstock Chain)]
Switching the UI over#
None of this replaced anything until it was validated against the existing client-side logic — running the subgraph and sync service alongside the old code path and comparing results before trusting either as the source of truth. Once that held up, the cutover was mechanical: the React app’s queries were pointed at the sync service’s API instead of at RPC calls, one query at a time.
The effect on the frontend was bigger than just fewer network calls. Most of the aggregation, derivation, and cross-contract joining that used to be hand-rolled logic inside the React store simply went away — the sync service now returns data shaped to match what a component needs, instead of raw events the client had to turn into that shape itself. The UI code got measurably simpler as a direct result, not just faster.
What it doesn’t solve#
An indexing layer is a read-path optimization, and it’s worth being honest about where that stops. A subgraph is a few blocks behind the chain head by construction — it has to wait for a block, process it, and handle the possibility that block gets reorganized out before treating it as final. If a UI needs the absolute latest pending state, the indexer isn’t the source for that. And none of this touches the write path: submitting a transaction still means talking to an RPC node directly, no matter how good your read-side indexing is. Cutting RPC usage by 90% here specifically meant 90% of read traffic — the piece that scales with every user’s every page view, which is exactly the piece that was dominating the bill and the latency. That figure comes from comparing RPC provider call-volume metrics before and after the cutover, not an estimate; the p95 end-to-end latency figure below is the browser-measured time for those same queries after the cutover.
Takeaway#
90% fewer RPC calls, p95 end-to-end query latency under 400ms, and a simpler frontend — but the process is the part worth repeating on the next project: observe where the cost actually is before designing anything, design the data model around the application’s real questions before indexing a single event, validate the new path against the old one before cutting over, and — for a decentralized application — be deliberate about which parts of the data path stay decentralized and which don’t, rather than letting convenience decide by default.
