Benchmarking Your Indexer
Every HyperIndex indexer reports what it's doing over an HTTP metrics endpoint while it runs. There's no benchmarking mode to enable and no profiler to attach — if the indexer is running, the numbers are already there.
This page turns those numbers into an answer to one question: what is my indexer waiting on, and what do I do about it?
Work through it in order:
- Measure throughput — how fast is it, in events per second?
- Find the bottleneck — which of five things is it waiting on?
- Fix it — the section for your bottleneck says what to change.
- Prove the fix worked — re-measure and compare.
Step 1: Measure throughput
Start the indexer in one terminal — locally with
envio dev, or in production with
envio start:
pnpm envio dev
Read its metrics from a second terminal, in the same project:
pnpm envio metrics # indexing metrics
pnpm envio metrics runtime # Node.js process metrics (CPU, memory, GC, event-loop lag)
The output is plain Prometheus text — a # HELP line describing each metric, a
# TYPE line, then one sample per line with its labels in braces:
# HELP envio_progress_events The number of events processed and reflected in the database.
# TYPE envio_progress_events gauge
envio_progress_events{chainId="1"} 158205
# HELP envio_process_elapsed_seconds How long the indexer has been running.
# TYPE envio_process_elapsed_seconds gauge
envio_process_elapsed_seconds 45.801
Because every metric documents itself, grepping for the metric you care about —
pnpm envio metrics | grep envio_progress — is usually the fastest way to
answer a specific question.
Take two snapshots a minute or so apart, and divide the change in events by the change in elapsed time:
pnpm envio metrics > t1.txt
# wait ~60s
pnpm envio metrics > t2.txt
grep -E '^envio_(progress_events|process_elapsed_seconds)' t1.txt t2.txt
# t1: 158205 events at 45.8s
# t2: 262000 events at 105.8s
# (262000 - 158205) / (105.8 - 45.8) = ~1730 events/second
envio_progress_events is reported per chainId, so a multichain indexer prints
one sample per chain: compare the same chainId across both snapshots for a
per-chain figure, or sum every chain within each snapshot for the indexer as a
whole. Both snapshots also have to come from one uninterrupted run — restarting
the indexer resets envio_process_elapsed_seconds, so start a fresh baseline
after any restart. (Prometheus rate() handles that for you when you scrape
instead.)
Interpret the result against what your workload plausibly allows:
| Events per second | Reading |
|---|---|
| Over 10,000 | Excellent — most likely bounded by the data source, not by you |
| 1,000–5,000 | Good. Worth tuning only if your sync time is still too long |
| Under 500 | Something is probably wrong. Continue to step 2 |
Once a chain is caught up (envio_progress_ready is 1), throughput reflects
how fast new blocks arrive, not how fast your indexer is. Benchmark while the
chain is still syncing history, or against a fixed block range with an
end_block in config.yaml, so two runs
are comparable.
Step 2: Find the bottleneck
A slow indexer is waiting on something. Two counters attribute that wait directly — read them first, because they point at the answer instead of hinting at it:
pnpm envio metrics | grep -E 'stalled_on|process_elapsed'
| Metric | Meaning | Go to |
|---|---|---|
envio_processing_stalled_on_fetch_seconds | Nothing to process — events hadn't been fetched yet | Fetching |
envio_processing_stalled_on_storage_write_seconds | Processing paused — too many changes queued for writing | Storage writes |
Both are cumulative seconds, so read them as a share of the run:
envio_processing_stalled_on_fetch_seconds / envio_process_elapsed_seconds
Anything above roughly 0.3 (30% of the run) is worth acting on. The same
division works for every _seconds counter — that's how you compare parts of
the pipeline against each other:
| Metric | Share of the run spent… |
|---|---|
envio_processing_seconds | inside your event handlers |
envio_preload_seconds | loading entities for a batch |
envio_storage_write_seconds | writing batches to storage |
envio_fetching_block_range_seconds | fetching block ranges (per chainId) |
envio_effect_call_seconds_total | inside Effect API calls (per effect) |
Only _seconds counters are durations you can divide by elapsed time. Counts
(envio_progress_events, envio_processing_handler_total) and gauges
(envio_progress_block, envio_indexing_concurrency) are read as values or as
deltas between snapshots. envio_process_metric_time_seconds records when a
snapshot was taken, so you can compute rates between two of them by hand.
If you scrape with Prometheus instead of reading snapshots, every counter above
works as a rate — rate(envio_processing_stalled_on_fetch_seconds[5m]) — see
Scraping with Prometheus.
Step 3: Fix the bottleneck
Each section below covers one bottleneck: how you recognise it, what it means, and what to change. Work only the one that step 2 pointed you at.
Fetching
How you know
pnpm envio metrics | grep -E 'stalled_on_fetch|indexing_idle|fetching_block_range_seconds'
envio_processing_stalled_on_fetch_seconds is a large share of the run, and
envio_fetching_block_range_seconds dominates the other _seconds counters.
envio_indexing_idle_seconds climbing on a specific chainId tells you which
chain is holding you up.
What it means
Your handlers are idle waiting for data. Time spent waiting at the chain head for new blocks is deliberately excluded from the stall counter, so a fully synced indexer never looks stalled here.
What to do
- Use HyperSync if it supports your network — it's the single biggest change available, and RPC is orders of magnitude slower.
- On RPC, a faster provider or a higher rate limit is the fix; check
envio_source_request_seconds_totalpersourceto see what each one costs you. - Compare
envio_indexing_concurrencywithenvio_indexing_partitionsfor the chain — if concurrency sits at its ceiling while events still arrive slowly, the source is saturated, not the indexer. - Watch
envio_fetching_block_range_parse_seconds: when parsing rivals fetching, you're decoding far more events than your handlers use, so narrow the events inconfig.yaml.
Entity loading
How you know
pnpm envio metrics | grep -E 'preload_seconds|storage_load'
envio_preload_seconds or envio_storage_load_seconds_total is a large share of
the run. envio_storage_load_size and envio_storage_load_where_size (labeled
by operation and storage) show which loads pull the most data.
What it means
Preload optimization batches the entity reads of a whole event batch into a few queries — but only for reads it can see during the preload phase. A read hidden behind a condition that only some events reach, or one that depends on the result of an earlier read, falls back to a single-event round trip.
What to do
- Read entities unconditionally near the top of the handler so the preload phase can batch them, rather than inside a branch.
- Avoid chaining reads (
get→ use the id you just read →getagain); load both entities up front where possible. - Add database indices for the fields you filter on with
getWhere. - Reduce how much each load returns: a
getWherethat matches thousands of rows costs more than the handler usually needs.
Handlers
How you know
pnpm envio metrics | grep -E 'processing_handler_(seconds|total)'
envio_processing_seconds is a large share of the run. To find the specific
handler, divide cumulative time by call count — both are labeled by contract
and event:
envio_processing_handler_seconds / envio_processing_handler_total
What it means
Your own code is the cost. A mean above ~1ms per call is worth a look; compare handlers against each other rather than against an absolute number.
What to do
- Move external calls (RPC, HTTP, IPFS) out of the handler and into the Effect API, which batches, parallelizes, and caches them.
- Keep heavy derived computation out of the hot path — store what you need and compute the rest at query time.
- Look for accidental work per event: parsing the same static data repeatedly, formatting, or building large intermediate objects.
Storage writes
How you know
pnpm envio metrics | grep -E 'stalled_on_storage_write|storage_write'
envio_processing_stalled_on_storage_write_seconds is a large share of the run,
and envio_storage_write_seconds is high next to envio_storage_write_total
(labeled by storage).
What it means
The indexer produced entity changes faster than storage accepted them, so processing paused until the write queue drained.
What to do
- Write fewer entities per event. Updating a running-total entity on every event makes each event a write; aggregating in memory and writing on a coarser boundary avoids most of them.
- Don't update the same entity several times within one handler — the last write wins anyway.
- Check your database: indices speed up reads but every extra index slows writes down, and an under-resourced Postgres shows up here first.
- If reorg support is enabled, entity history is written alongside your entities —
envio_rollback_history_prune_secondsperentityshows what that costs.
External calls
How you know
pnpm envio metrics | grep -E 'envio_effect_'
envio_effect_call_seconds_total is a large share of the run, or
envio_effect_queue_wait_seconds shows calls waiting on a rate limit. Both are
labeled per effect, so the slow one names itself.
What it means
Handlers are blocked on Effect API calls to something outside the indexer — an RPC node, an HTTP API, IPFS.
What to do
- Set
cache: trueon effects whose result is stable for a given input, so a rerun doesn't repeat the call;envio_effect_cachetracks cache size andenvio_effect_cache_invalidationshow often it's discarded. - Raise the effect's
rateLimitif the provider allows it —envio_effect_queuesitting above zero means calls are queuing rather than running. - If effects are slow and can't be batched, lower
full_batch_sizeso a batch doesn't wait on thousands of pending calls. - Compare
envio_effect_call_seconds_totalwithenvio_effect_call_totalto separate "each call is slow" from "there are too many calls".
Step 4: Prove the fix worked
Benchmarking is only useful as a loop:
- Capture a baseline before changing anything:
pnpm envio metrics > before.txt. - Change one thing. Two changes at once make it impossible to attribute the difference.
- Re-run over the same block range, ideally from a fresh database so the run does the same work.
- Compare the same numbers: events per second from step 1, and the share of the run for the counter you targeted.
pnpm envio metrics > after.txt
diff <(grep -E '^envio_(progress_events|.*_seconds)' before.txt) \
<(grep -E '^envio_(progress_events|.*_seconds)' after.txt)
Two things to watch while you iterate:
- Memory.
pnpm envio metrics runtimereports heap usage, GC, and event-loop lag. An indexer that speeds up while memory climbs is often deferring work rather than avoiding it. - Realistic conditions. Benchmark against a block range with the event mix your indexer actually sees — a quiet range flatters every change you make.
Next Steps
- Observability — the full metric reference, logs, health checks, and
_metaindexing status - Preload Optimization — how batched entity reads work
- Effect API — batching, parallelizing, and caching external calls
- Database Performance — indices and schema design
- Latency at the Head — what to expect once the indexer is caught up
- Still stuck? Share your metrics snapshot in Discord — we're happy to read it with you.