back

Rick ·

The Graph vs Ponder vs a custom indexer: which should I use?

Dev tooling

I built the same feature three times — once as a subgraph on The Graph, once in Ponder, once as a hand-written indexer — and pointed all three at the same local chain.

All three produced the same answer, to the wei.

Everything else was different. The custom indexer needed 2 RPC requests where the frameworks needed 444 and 642. Ponder's process tree held 747 MiB of memory; the hand-written one held 87 MiB. And when I rewound the chain and mined a different history, the custom indexer kept serving stale numbers without a single error in its logs.

That last result is the one that should decide this for most people, so it is worth explaining how it was produced.

The feature and the fixture

The subject is a staking contract with two events:

// examples/articles/the-graph-vs-ponder-vs-a-custom-indexer-which-should-i-use/contracts/src/Staking.sol
    event Staked(address indexed user, uint256 amount);
    event Unstaked(address indexed user, uint256 amount);

The events carry the delta, not the resulting balance. That is deliberate. An event that includes the new balance can be indexed by reading the last log per account, which is a job for eth_getLogs and not for an indexer. Carrying only the delta forces every approach to maintain running state in the same order the chain produced it, which is the thing these tools actually exist to do.

The feature is a leaderboard: for each account, its current staked balance, how many times it staked, how many times it unstaked, and the block of its last action.

The chain is Anvil 1.5.1 with chain id 31337. A generator script deploys the contract and produces a deterministic workload from a fixed seed: 401 blocks, 999 events across 8 accounts, one to four events per block, 596 stakes and 403 unstakes. The script asserts its own bookkeeping against the contract's storage before writing anything down, and the comparison uses the contract's balanceOf at the final block as ground truth rather than anything the script believes it sent.

Versions: graph-node v0.45.0 (released 3 August 2026), @graphprotocol/graph-cli 0.98.1, @graphprotocol/graph-ts 0.38.2, ponder 0.17.10, viem 2.56.8, Node.js 22.17.0, Docker 28.2.2, ipfs/kubo v0.17.0 and postgres:17-alpine. The machine is an Apple M3 Pro with 12 logical CPUs, 36 GiB of memory, Darwin 25.5.0 on arm64.

The indexing code is nearly identical

Once you get past the setup, the three handlers say the same thing three ways.

// examples/articles/the-graph-vs-ponder-vs-a-custom-indexer-which-should-i-use/subgraph/src/staking.ts
export function handleStaked(event: Staked): void {
  const position = load(event.params.user);
  position.balance = position.balance.plus(event.params.amount);
  position.stakeCount = position.stakeCount + 1;
  position.lastBlock = event.block.number.toI32();
  position.save();
}
// examples/articles/the-graph-vs-ponder-vs-a-custom-indexer-which-should-i-use/ponder-indexer/src/index.ts
ponder.on("Staking:Staked", async ({ event, context }) => {
  await context.db
    .insert(position)
    .values({
      user: event.args.user,
      balance: event.args.amount,
      stakeCount: 1,
      unstakeCount: 0,
      lastBlock: Number(event.block.number),
    })
    .onConflictDoUpdate((row) => ({
      balance: row.balance + event.args.amount,
      stakeCount: row.stakeCount + 1,
      lastBlock: Number(event.block.number),
    }));
});

Counting the files a developer types by hand, and excluding generated output and the ABI, the subgraph's manifest, schema and mapping come to 61 lines of code; Ponder's config, schema, handlers and API route come to 61 as well. The custom indexer is 110 lines in one file — and those extra 49 lines are almost entirely the machinery the other two hand you: a database schema, upsert statements, a sync cursor, an HTTP server, a readiness endpoint.

The AssemblyScript mapping is the only one that fights you. graph codegen produces the entity and event types, but the language is not TypeScript — there is no ??, and null handling is explicit enough that the "load or create" helper is a separate function rather than an expression.

What you actually have to run

Three rows comparing what each approach runs. A custom indexer is one Node process with viem and node:sqlite, 13 npm packages, and 2 RPC requests. Ponder is one process in development with PGlite embedded and PostgreSQL in production, 261 npm packages, and 444 RPC requests. The Graph self-hosted is graph-node plus separate PostgreSQL and IPFS containers, 503 npm packages for its command line tools, and 642 RPC requests.

Request counts are what crossed a counting proxy while backfilling the same 401 blocks. Package counts come from each project's lockfile.

Custom Ponder The Graph, self-hosted
Processes or containers 1 1 in dev, 2 in production 3
npm packages installed 13 261 503
node_modules on disk 75 MiB 211 MiB 197 MiB
Container images — — 707 MB
Resident memory 87–103 MiB 747–751 MiB 96 + 78 + 69 MiB

The Graph's three services are not a deployment style I chose; they are what graph-node's own docker-compose.yml declares. PostgreSQL holds the entity store; graph deploy uploads the manifest, schema and compiled WASM to IPFS and hands graph-node the resulting hash.

Two things about that file are worth knowing before you copy it. It pins IPFS to ipfs/kubo:v0.17.0, a 2022 release, while leaving both graph-node and postgres untagged — so docker compose up gets whatever latest happens to be that day. And the published graphprotocol/graph-node image is linux/amd64 only; on Apple Silicon Docker reports a platform mismatch and runs it emulated. Every graph-node timing below was measured under that emulation.

Ponder's single process is the development configuration. It embeds PGlite by default, and its database documentation calls that "only suitable for local development" and requires PostgreSQL in production. Its chain configuration page is equally direct about the other dependency: "Most Ponder apps require a paid RPC provider plan to avoid rate-limiting."

The measurement below is why that warning is there.

The RPC bill

I put a counting proxy between each indexer and Anvil and recorded every JSON-RPC call made while backfilling the same 401 blocks.

Approach Requests Dominant methods
Custom 2 1 × eth_blockNumber, 1 × eth_getLogs
Ponder 444 406 × eth_getBlockByNumber, 37 × eth_getLogs
graph-node 642 385 × eth_getBlockByHash, 234 × eth_getBlockReceipts

That is a factor of 222 and 321 against the hand-written version, for byte-identical output. Over the wire that is 353 bytes of request bodies against 38 KiB and 91 KiB.

If you are paying per request, that ratio is the line item — the same arithmetic that dominates the real cost of running an Ethereum app. The obvious reading is that the frameworks are wasteful. The honest reading is that they are buying something. A single eth_getLogs over a block range returns logs and nothing else — no block timestamps, no parent hashes, no receipts. My custom indexer can get away with two calls precisely because it never asks what the parent of a block was, which is the same reason it cannot notice a reorg. graph-node fetches blocks by hash because it is walking a chain, not a range.

One number in that table is an artifact of the fixture rather than of Ponder: the chain is local, so disableCache: true is set as Ponder's Foundry guide instructs, and nothing is reused between runs. Against a real chain Ponder keeps a durable cache in a ponder_sync schema, and a restart re-reads far less. The per-block pattern is still the pattern.

Cold start

Horizontal bars of cold time to a correct answer. Custom indexer 0.21 seconds, Ponder 23.3 seconds with a mark showing slash-ready returning 200 at 20.1 seconds, and The Graph 15.8 seconds of which 11.7 seconds is bringing up its three containers.

Median of three runs; every observation marked. Times are from nothing running to an API that answers correctly at block 401.

The clock starts when the first process is spawned and stops when the approach's own API reports it has indexed through the head block. For The Graph that includes bringing the three containers up from a wiped Postgres volume, because that is what "cold" means when a database is part of the deployment.

Split apart, graph-node spent 11.7 s starting its stack, 2.53 s on graph create plus graph deploy, and 1.48 s actually indexing 999 events. That last figure is the interesting one: the fastest backfill of the three, from the approach with the slowest start, under emulation.

Ponder's 23.3 s is dominated by neither startup nor handler execution. Its server answered /health at a median of 2.70 s, and its own logs report 4–8 ms per indexed block, which accounts for roughly two seconds. The rest is the 406 sequential block fetches from the table above.

The custom indexer's 208 ms is not a fair fight and should not be read as one. It does one range query and writes to an in-process SQLite database. It is the number you get when you skip everything the other two are doing.

Ponder says it is ready before it is

While measuring, /ready turned 200 while the API was still 30 blocks short. It was not a race. Across six runs at two different chain heights, the block the API reported at the instant /ready flipped was the head minus exactly 30 — block 371 against a head of 401, and block 374 against a head of 404.

The reason is in the installed package rather than the documentation. node_modules/ponder/dist/esm/utils/finality.js maps chain ids to a finality block count — 65 for Ethereum mainnet and its testnets, 200 for Polygon, 240 for Arbitrum — and falls through to a default:

// node_modules/ponder/dist/esm/utils/finality.js (ponder 0.17.10)
        default:
            // Assume a 2-second block time, e.g. OP stack chains.
            finalityBlockCount = 30;

Anvil's chain id is not in that list, so 30 applies. /ready returns 200 once the finalized range is indexed, and the unfinalized tail arrives shortly after — a median of 3.1 s later across those six runs.

That is defensible behaviour, and Ponder's Railway guide tells you to set the healthcheck path to /ready with a one-hour timeout, which is the right advice: it is a readiness signal, not a completeness one. But if you cut traffic over to a new deployment the instant /ready goes green, on an OP-stack chain you are serving a leaderboard that is 30 blocks stale. Check the indexer's reported block against the chain head as well.

Then I reorganized the chain

All three indexers were caught up and correct at block 401. I then rolled Anvil back six blocks and mined a different nine-block history in their place, so the head moved past its old value. Ninety seconds later:

Reported block Balances matching the chain
Custom 404 1 of 8
Ponder 404 8 of 8
graph-node 404 8 of 8

graph-node's log narrates exactly what it did:

INFO Reverting block to get back to main chain, revert_to_ptr: #400 (721230f5…), subgraph_ptr: #401 (c48b2d78…), …
INFO Reverting block to get back to main chain, revert_to_ptr: #399 (cf697cc6…), subgraph_ptr: #400 (721230f5…), …
INFO Reverting block to get back to main chain, revert_to_ptr: #398 (a632812e…), subgraph_ptr: #399 (cf697cc6…), …

Hashes and trailing fields abridged; the full lines are in the run's results file.

It walked back to the common ancestor, undid the entity writes, and re-indexed the replacement blocks. Ponder did the same thing quietly — this is what the 30-block finality window is for, and a six-block reorg falls inside it.

The custom indexer did nothing wrong by its own logic. It had already indexed through block 401, the new head was 404, so it indexed the blocks it had not seen before and reported itself healthy. Seven of eight balances were wrong. There was no error, no warning, and no way for a consumer of that API to tell.

This is the part that is easy to underestimate when you are writing the hand-rolled version. Reorg handling is not a feature you add later next to pagination. It changes the data model: you need block hashes, a record of which writes came from which block, and the ability to undo them. The 2-versus-642 request ratio is mostly the price of that capability.

The parts that are not measurable but still decide it

For The Graph, the decision usually turns on where your chain is and who pays for queries.

A local or private chain is not on the decentralized network, and the supported networks page says so plainly: if your network is not supported, run your own graph-node. It listed 58 mainnet rows on 23 September 2026, with testnets behind a toggle; the marketing page says "60+ networks". There is also a lighter documented local path I did not measure — Graph Node Dev Mode, which names Anvil explicitly and, per its own docs, removes the IPFS requirement and starts a temporary Postgres for you.

If you do publish to the network, Subgraph Studio gives 100,000 free queries a month and then charges "$2 per 100,000 queries". The free tier has a fair use policy effective 15 May 2025: up to 10 GB of storage, deletion after 30 days of no usage, and a requirement that a subgraph sync within 14 days of deployment. Publishing itself is an on-chain transaction on Arbitrum One, and the docs recommend curating your own subgraph with at least 3,000 GRT to attract indexers if it is reward-eligible. The old hosted service is gone; its upgrade window was announced to end on 12 June 2024.

Two maintenance signals are worth weighing. @graphprotocol/graph-cli's latest tag has not moved since 0.98.1 on 16 October 2025, though alpha builds ship almost daily. And matchstick-as, the unit testing framework the docs point you to for mapping logic, is still on 0.6.0 from 6 October 2023.

Ponder's risk is a different shape. It is MIT licensed and genuinely self-hostable, but it is pre-1.0 with breaking changes in minor releases — networks became chains in 0.11, event addresses stopped being checksummed in 0.12 — and the commit history is dominated by two people. Its hosted platform, Marble, was documented in 2025 and the page was deleted in February 2026; there is no paid Ponder product today. Its self-hosting page is short, points at Railway as the worked example, and does not mention Docker once.

What I would choose

If the chain is supported and someone else should run the infrastructure, publish a subgraph. Three containers is a real operational commitment, and the whole point of the network is not making it. The 1.48 s backfill says graph-node is not the slow part of that story.

If you want to own the deployment, or you are indexing a chain nobody hosts, use Ponder. You get reorg handling, a typed schema, and a query API for 61 lines of code and one extra process — and you should budget for a paid RPC plan, because the per-block fetch pattern is not optional.

Write your own only when you can say out loud what you are giving up. A range query and an append-only table is a fine way to build a dashboard over finalized history. It is not a fine way to serve balances, because the failure mode is not an outage — it is an API that stays up and answers wrong.

The reproduction is in examples/articles/the-graph-vs-ponder-vs-a-custom-indexer-which-should-i-use: the contract, all three implementations, the workload generator, the counting proxy, the reorg harness, and the raw results each figure was drawn from.

This is one workload on one machine: 999 events, 401 blocks, one contract, one chain, no factory patterns, no eth_call from a handler, and no multi-chain indexing. A larger backfill would change the absolute numbers. It would not change which of the three notices that the chain moved underneath it.