How subgraphs actually work, from someone who ships them

June 16, 20265 min readBy Harman Kamboj
Web3Engineering

The first time I deployed a subgraph I assumed it was basically a database with a fancy GraphQL layer bolted on. That mental model held up for about a day. The Graph subgraphs are closer to a small streaming pipeline that replays the chain block by block, runs your code against every matching event, and writes the result into an entity store that a query layer reads from. Once you see it that way, the weird parts stop being weird.

What a subgraph really is under the hood

A subgraph has three moving parts that matter. There is the manifest, which says which contracts and events you care about and which handler runs for each. There is the schema, which defines the entities you store and the relationships between them. And there is the mapping code, written in AssemblyScript, which is the only place you get to do anything interesting. When an indexer syncs your subgraph it walks the chain from your start block forward, and for every log that matches your manifest it calls your handler with a typed event object.

The part people miss is that your mapping code is not a server. It does not stay running. It is invoked once per matching event, does its work, returns, and is invoked again for the next one. There is no shared memory between calls except what you have written to the store. If you find yourself wanting a global variable that survives across events, you actually want an entity.

The store is the whole game

Every meaningful thing a subgraph does ends in a store.set or a store.get. The store is keyed by entity type and id, and that id is the single most important design decision you will make. Get it right and your queries are trivial. Get it lazy and you end up doing string concatenation gymnastics six months later trying to look up a record you can no longer address.

My rule is that an entity id should be derivable from data you already have in hand at the moment you need to read or write it. For a transfer that might be the transaction hash plus the log index. For a position it might be the user address plus the pool address. The moment an id requires you to query something else just to construct it, you have a smell worth fixing.

  • Use deterministic ids you can rebuild from event data, not auto-increment counters
  • Concatenate id parts with a separator so two short values can never collide with one long one
  • Store the raw values you keyed on as fields too, so queries can filter without parsing ids

Handlers are cheaper than you think, until they are not

A common early mistake is treating each handler as free. They are cheap individually. The problem is volume. If your contract emits a few million events and your handler does three store.get calls each, you are doing tens of millions of reads during a full sync, and those reads are the bottleneck, not the writes. I have watched a sync go from days to hours just by cutting one unnecessary lookup per event.

The fix is usually to stop re-reading things you can carry forward. If a handler needs the running total for an account, and the previous handler for that same account already computed it, you still have to read it from the store because there is no memory between calls. But you can avoid reading three related entities when one would do, and you can avoid loading an entity just to check whether it exists when a null check on the load already tells you that.

Reorgs and why your data is never quite final

This is the thing that bites people coming from normal backend work. The chain is not append-only at the tip. Recent blocks can be reorganized away, and when that happens an indexer has to roll your subgraph back to a safe block and replay forward over the new history. The Graph handles the rollback for you, which is wonderful, but it only works because the store is versioned by block. Your handlers have to be pure functions of the chain state up to that block. If a handler reaches out to something nondeterministic, the rollback cannot reproduce the same result and your data quietly drifts.

In practice this means no calls to external services from a handler, no wall-clock time, and real care with contract calls that read mutable on-chain state. A contract call inside a handler returns the value as of the block being processed, which is correct, but it is also slow and it ties your sync speed to RPC latency. I avoid them unless the data genuinely cannot be reconstructed from events. Most of the time it can.

The advice I give people starting out

Model your schema first and your handlers second. The schema is the contract with everyone who will ever query your data, and it is the expensive thing to change because changing it means a full resync. The handlers are just plumbing you can refine. I have rewritten mapping code many times. I try very hard never to rewrite a schema in production.

And test against real chain data early. A subgraph that works on the three events you triggered by hand on a testnet will surprise you the moment it meets a contract that does something the docs never mentioned. The chain is full of edge cases. The whole point of a subgraph is to absorb them so your frontend never has to, and you only find them by syncing against history that actually happened.

Building something where this matters?

I am open to senior full-stack, Web3, or AI engineering roles, fully remote and any timezone. If the hard part of your product is fighting you, that is the work I like.

Get in touch →