Skip to main content
Goldsky runs standard 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.
Goldsky permanently caches the RPC calls a subgraph makes during indexing. Re-syncing the same or a similar subgraph reuses cached eth_call and log results, so a re-sync is much faster than the first sync. You generally don’t need to engineer around RPC cost on a re-sync.

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
Faster:
schema.graphql
Only use 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 as String 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
Faster:
schema.graphql
src/mapping.ts
For composite keys, concatenate the parts while staying in Bytes, for example account.concat(token).
Bytes ids sort by hex value, not numerically. If you need to order or paginate entities in sequence, add an explicit BigInt field (like blockNumber or a counter) and sort on that instead of the id.

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
Faster:
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
Faster:
src/mapping.ts
Before adding any contract call to a handler, check whether the value is already in event.params. For most protocols, the event payload carries everything the handler needs.

eth_calls: avoid, declare, or cache

An eth_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:
  1. Avoid the call. If the data is in the event payload, read it from event.params (see above).
  2. Declare the call. Declared eth_calls are listed in the manifest so graph-node executes them in parallel ahead of time and serves your handler from cache. This requires specVersion: 1.2.0 or 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.
  3. 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.
A declared call looks like this in the manifest:
subgraph.yaml
And here’s the caching pattern for immutable metadata: Slower:
src/mapping.ts
Faster:
src/mapping.ts
The 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
Faster:
schema.graphql
Your mapping only creates the 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 with startBlock: 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
You can find the deployment block on a block explorer as the block of the contract-creation transaction.

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.
If the changes that would actually help require schema changes, the realistic option is to rebuild leaner and re-sync from scratch: keep the live-state entities and raw event logs (immutable, 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:
If a subgraph stops making progress entirely, Goldsky detects it, emails your team, and may automatically pause it; see Stalled subgraph detection for how that works and how to recover. If progress is steady but slow, work through the checklist below: the cause is almost always per-event work.
Every deployed subgraph version is billed separately (worker fee plus entity storage). Deleting old versions you no longer query is the cheapest, highest-impact optimization you can make.

Checklist

  • Append-only event entities are @entity(immutable: true) with Bytes ids
  • 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
  • startBlock set to the contract’s deployment block
  • Pruning configured if you don’t need deep history
  • Old versions deleted