graph-node, so how fast your subgraph indexes and queries is decided almost entirely by how you design it: how much work each handler does per event, and how the schema stores data. Indexing resources are not the bottleneck: a slow subgraph is almost always slow because of its design or upstream RPC performance, and the fixes below target the design.
Each recommendation shows a slow pattern and its faster replacement. Most apply at authoring time; a few (like pruning) can be added to an existing subgraph.
Mark append-only entities immutable
By default,graph-node tracks block ranges and versions for every entity so it can serve historical queries and handle updates. Entities that are written once and never change (transfers, swaps, mints) don’t need any of that bookkeeping. Marking them immutable: true skips it, so they both index and query faster.
Slower:
schema.graphql
schema.graphql
immutable: true for entities that are never updated after creation. Anything that changes over time (balances, pool reserves, positions) must stay immutable: false.
Current versions of
graph-cli require an explicit immutable argument on every @entity. A bare type X @entity { ... } fails graph codegen and graph build, so write @entity(immutable: false) or @entity(immutable: true) on every type.Use Bytes ids for hashes and addresses
Addresses, transaction hashes, and ids built from them are hex values. Storing them asString roughly doubles their storage size and makes comparisons slower. Use Bytes and build ids with byte concatenation instead of string concatenation.
Slower:
schema.graphql
src/mapping.ts
schema.graphql
src/mapping.ts
Bytes, for example account.concat(token).
Derive lists instead of storing arrays
Storing a growing array on a parent entity degrades badly as it grows: every update rewrites the entire array, and very large arrays (tens of thousands of elements) can time out. Model the relationship on the child entity and derive the list on the parent with@derivedFrom.
Slower:
schema.graphql
src/mapping.ts
schema.graphql
src/mapping.ts
@derivedFrom fields are virtual: they take no storage and are resolved at query time, so they stay fast regardless of how many children exist. For many-to-many relationships, model a join entity (for example PoolMembership with pool and account fields) instead of arrays on either side.
One caveat: derived fields belong in queries, not in hot mapping paths. If a handler needs an aggregate like a count or running total, keep a scalar field (for example txCount: BigInt!) up to date instead of reading the collection back.
Do less work per event
Handler cost multiplies by event count, so a small amount of waste in a hot handler becomes hours of sync time. The two most common forms of waste are contract calls for data that’s already in the event, and loading or saving entities the handler doesn’t actually change. Slower:src/mapping.ts
src/mapping.ts
event.params. For most protocols, the event payload carries everything the handler needs.
eth_calls: avoid, declare, or cache
Aneth_call during indexing is a synchronous RPC round-trip, which makes it the single most expensive thing a handler can do. In order of preference:
- Avoid the call. If the data is in the event payload, read it from
event.params(see above). - Declare the call. Declared eth_calls are listed in the manifest so
graph-nodeexecutes them in parallel ahead of time and serves your handler from cache. This requiresspecVersion: 1.2.0or higher and only works when the call is computable from event parameters alone; it can’t depend on state your mapping computed. See Use declared eth_calls for the full guide. - Cache immutable results. Values that never change (a token’s
symbol,name,decimals) should be fetched once, on first sight, and stored on an entity. Never re-fetch them per event.
subgraph.yaml
src/mapping.ts
src/mapping.ts
try_ prefix matters for reliability, not just speed: a contract whose decimals() or symbol() reverts is one of the most common causes of a fatally crashed subgraph. Default the value and keep indexing. You can test these revert paths locally; see Testing subgraphs.
If you’re using instant (no-code) subgraphs, enrichment calls support the same optimization: set declared: true on the enrichment call (it’s ignored on call handlers). See the instant subgraph configuration reference.
Let the database compute aggregates
For high-volume rolling metrics (daily volume, hourly counts), the classic pattern of loading a stats entity, adding to a total, and saving it back runs on every event. Timeseries and aggregation entities move that work into the database. Slower:src/mapping.ts
schema.graphql
Swap rows; the hourly and daily totals are computed by the database. Aggregation functions include sum, count, min, max, first, and last.
Timeseries entities require an
Int8 id and a Timestamp field, and timeseries support is a newer graph-node feature. Confirm your manifest’s specVersion and apiVersion support it, and validate with a small test deploy before building a large subgraph around it.Start at the contract’s deployment block
A subgraph withstartBlock: 0 scans the chain’s entire history before it finds your contract’s first event. Set startBlock to the block the contract was deployed at (or the earliest block you care about):
subgraph.yaml
Prune history you don’t need
graph-node keeps historical entity state so you can run time-travel queries at old blocks. If you only query current state, that history is pure overhead. Pruning limits how much is retained, which shrinks storage and speeds up queries:
subgraph.yaml
auto keeps the minimum history needed. The trade-offs: you can’t graft a new version at a pruned block, and time-travel queries below the pruned range won’t work. Use never if you need full history or plan to graft from old blocks.
Grafting won’t make indexing faster
Grafting starts a new version from an existing version’s already-indexed data at a chosen block, and Goldsky fully supports it. But if your goal is “make this subgraph faster without re-indexing from scratch,” be clear about what grafting does:- Grafting only skips re-processing old blocks. The remaining blocks index faster only if the new code does less work per event.
- The biggest speedups break graft compatibility. Making entities immutable, changing ids to
Bytes, or restructuring relationships all change the schema, and you can’t graft across a schema change. - Graft-safe changes are manifest-level and mapping-internal only: declaring eth_calls, trimming work inside handlers, removing an unused handler. These keep the schema identical, but they often don’t touch a design-bound bottleneck.
Bytes ids, @derivedFrom collections), and drop expensive machinery you don’t query: per-event USD pricing, unused token metadata, hand-rolled day/hour aggregates, and per-event eth_calls. Deploy the lean version under a new name, leave the original running, and cut your app over once the new version has synced. Because each block does far less work, this routinely turns a multi-day sync into hours, and Goldsky’s permanent RPC cache means the re-sync reuses previously fetched call results.
Spot slow indexing
Watch a deployed subgraph’s indexing progress on its dashboard, or check status and error logs from the CLI:Checklist
- Append-only event entities are
@entity(immutable: true)withBytesids - Every one-to-many relationship uses
@derivedFrom, no stored arrays - No per-event eth_calls: read from
event.params, declare calls (specVersion: 1.2.0+), or cache immutable metadata - Timeseries and aggregations for rolling metrics instead of load-modify-save totals
startBlockset to the contract’s deployment block- Pruning configured if you don’t need deep history
- Old versions deleted