DEV Community

Cover image for Designing High-Performance Blockchain Indexers
beefed.ai
beefed.ai

Posted on • Originally published at beefed.ai

Designing High-Performance Blockchain Indexers

When event indexing lags, the symptoms are obvious and painful: stale balances and missing transfers on user profiles, GraphQL endpoints returning incomplete timelines, production backfills that spike CPU and I/O and crush primary databases, and subtle correctness bugs caused by mishandled reorgs and duplicate events. You notice patterns: head processing keeps up for a while, historical queries choke the store, reorgs trigger mass rollbacks, and operational work escalates from a few minutes to overnight engineering sprints. Those symptoms tell you where the architecture needs to change: ingestion and storage, not just more RPC nodes.

Contents

  • Why latency and reliability are the product
  • When streaming wins and when batch beats streaming
  • Data modeling decisions: Postgres or ClickHouse for blockchain indexers?
  • Ingestion strategies: batching, backfills, and strong eventual consistency
  • Operational reliability: scaling, observability, and runbooks that save nights
  • Practical application: checklists and runbook snippets you can use

Why latency and reliability are the product

A production dApp lives or dies on its read model. The on‑chain ledger intentionally favors immutability over fast random reads; the indexer converts append‑only blocks into the user experience — fast search, current balances, event timelines, and deterministic business logic. That translation has two hard requirements: low tail latency for user‑facing reads and high correctness under chain churn (reorgs, forks, dropped transactions). Design choices that prioritize one at the expense of the other produce either fast-but-incorrect results or correct-but-uselessly-slow APIs.

Important: Decide up front whether a given API is authoritative (your database is the source of truth) or advisory (data can be slightly stale and reconciled later). That decision drives data modeling, storage choice, and recovery procedures.

Practical trade-offs you’ll confront immediately:

  • Event indexing that favors raw append throughput (good for analytics) will typically make single-entity lookups slower or more complex.
  • Pushing all load into a single DB without materialized views or aggregates creates unpredictable tail latency under mixed workloads.
  • Microservices and caches can hide problems temporarily; a root‑cause fix usually requires rethinking ingestion and storage.

When streaming wins and when batch beats streaming

Streaming wins when you need the freshest possible view and predictable incremental updates: head syncing, account balances, orderbooks, notification feeds, and immediate GraphQL subscriptions. Streaming pipelines — typically node → ingest service → message bus → consumers → store — decouple sources and sinks, allow parallel consumers, and reduce end‑to‑end latency. Apache Kafka is the canonical choice for that bus because it gives you durable, partitioned ordering and consumer lag visibility for scaling drives.

Batch processing wins for broad historical analysis, expensive joins, and large reindex/backfill jobs. A bulk replay of logs across millions of blocks is more efficient if you stream blocks to workers in coarse windows (e.g., 1k–10k blocks) and let those jobs perform heavy aggregation without blocking low‑latency traffic.

A practical, hybrid pattern works best in most deployments:

  • Use streaming (with micro‑batches) for hot paths and user‑facing state.
  • Use batch jobs for backfills, reporting, and schema changes.
  • Keep the two systems decoupled so a heavy backfill cannot exhaust the resources of the streaming path.

Example micro‑batch consumer (Go pseudocode) — this pattern reduces write amplification while keeping tail latency bounded:

// micro-batch consumer sketch
batchSize := 500
batchTimeout := 500 * time.Millisecond
events := make([]Event, 0, batchSize)
timer := time.NewTimer(batchTimeout)

for {
  select {
  case ev := <-eventCh:
    events = append(events, ev)
    if len(events) >= batchSize {
      process(events)
      events = events[:0]
      timer.Reset(batchTimeout)
    }
  case <-timer.C:
    if len(events) > 0 {
      process(events)
      events = events[:0]
    }
    timer.Reset(batchTimeout)
  }
}
Enter fullscreen mode Exit fullscreen mode

Be explicit about ordering guarantees, idempotency, and commit semantics when you design micro‑batches; dead‑reckoning on these leads to duplication or lost events.

Data modeling decisions: Postgres or ClickHouse for blockchain indexers?

Your storage choice dictates schema design, query patterns, and recovery strategies. Here’s a focused comparison:

Characteristic Postgres ClickHouse Best fit
Data model Row‑oriented, mutable, ACID Columnar, append/merge, analytically optimized Point-get + transactional state (Postgres); timeline scans and analytics (ClickHouse)
Typical latency Low for single-row lookups Low for large aggregates, higher for many small point queries Fast single-entity endpoints → Postgres; heavy scans/time-series → ClickHouse
Update semantics In-place updates, INSERT ... ON CONFLICT upserts Append and merge engines (ReplacingMergeTree, CollapsingMergeTree) Updatable state → Postgres; immutable event stream → ClickHouse
Scaling Vertical + replicas + partitioning Distributed shards, replication, extremely high ingestion throughput Use both in complementary roles
Cost profile Higher for large analytic scans Cost effective for large-scale analytics Hybrid architectures save costs and avoid hotspots

Choose Postgres to serve single-entity, transactional, low‑cardinality endpoints: balances by address, allowance lookups, and user‑specific views. Use jsonb for flexible event payloads and GIN indices for ad hoc queries when needed. Postgres supports ACID transactions and ON CONFLICT upserts that simplify idempotent writes — core capabilities for authoritative state.

Choose ClickHouse for high‑cardinality, time‑series, and analytic workloads: event timelines, transfer histories, aggregate dashboards, and fraud detection. ClickHouse’s MergeTree family and columnar compression give orders‑of‑magnitude performance and storage efficiency for scans and group-bys. Use ReplacingMergeTree or CollapsingMergeTree to handle deduplication and tombstones when you ingest events idempotently.

Schema patterns (examples)

Postgres: single‑source-of‑truth for current state

CREATE TABLE account_state (
  address TEXT PRIMARY KEY,
  balance NUMERIC,
  last_updated_block BIGINT,
  metadata JSONB
);

CREATE TABLE events (
  block_number BIGINT,
  tx_hash BYTEA,
  log_index INT,
  contract_address TEXT,
  event_name TEXT,
  args JSONB,
  PRIMARY KEY (tx_hash, log_index)
);
Enter fullscreen mode Exit fullscreen mode

ClickHouse: append‑optimized timeline for analytics

CREATE TABLE events_ch (
  block_number UInt64,
  tx_hash String,
  log_index UInt32,
  contract_address String,
  event_name String,
  args JSON String,
  timestamp DateTime
) ENGINE = ReplacingMergeTree(timestamp)
PARTITION BY toYYYYMM(timestamp)
ORDER BY (contract_address, block_number, tx_hash, log_index);
Enter fullscreen mode Exit fullscreen mode

Use ClickHouse for event processing that requires scanning millions of rows per query; use Postgres for the authoritative, updatable state.

Ingestion strategies: batching, backfills, and strong eventual consistency

Designing ingestion answers three questions: how you read blocks/logs, how you commit indexed state, and how you recover from forks/reorgs.

  1. Read path options

    • Passive RPC polling (eth_getLogs, block by block) is simple but struggles at scale.
    • Websocket subscriptions and mempool watchers capture pending txs for proactive UIs.
    • Use a durable message bus (Kafka) to decouple ingestion from indexing consumers and to get visibility on consumer lag and replay semantics.
  2. Commit semantics and idempotency

    • Use a deterministic dedupe key that combines tx_hash + log_index (and block_number for ordering). Write idempotent "upsert" logic for Postgres using ON CONFLICT to avoid duplicates.
    • For ClickHouse, rely on MergeTree variants for deduplication (e.g., ReplacingMergeTree with a version column or CollapsingMergeTree with sign), and always design the pipeline so replayed batches do not corrupt the aggregate state.

Postgres upsert example:

INSERT INTO events (block_number, tx_hash, log_index, contract_address, event_name, args)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (tx_hash, log_index) DO UPDATE
SET args = EXCLUDED.args, block_number = EXCLUDED.block_number;
Enter fullscreen mode Exit fullscreen mode

ClickHouse dedupe note: ClickHouse merges duplicates asynchronously; you must design consumers to tolerate eventual de-duplication and avoid relying on immediate uniqueness unless you implement compensating logic.

  1. Reorg handling

    • Do not mark events as immutable until you reach N confirmations appropriate for the chain and your risk profile; many teams choose 6 for Ethereum mainnet, but choose based on the chain and the economic risk.
    • Maintain a mapping of block_number -> block_hash in your indexer’s control table. When the canonical hash at a block number changes, identify affected events and reprocess the window.
    • Implement an "optimistic apply, confirm later" pattern for UX: present unconfirmed state with a clear flag, then finalize once the block reaches confirmation threshold.
  2. Backfills and reindex orchestration

    • Break large backfills into bounded windows (e.g., 5k–50k blocks depending on CPU and RPC throughput).
    • Parallelize by block range and write into a staging schema or topic so you can run diffs and swap atomically.
    • Checkpoints: commit progress per worker into a control table so resumption after failure is deterministic.

Backfill orchestrator sketch (Python pseudocode):

def backfill(start, end, window=5000, workers=8):
    ranges = [(b, min(b+window-1, end)) for b in range(start, end+1, window)]
    with ThreadPoolExecutor(max_workers=workers) as ex:
        for r in ranges:
            ex.submit(replay_and_write, r)
Enter fullscreen mode Exit fullscreen mode
  1. Consistency models
    • Provide API-level signals: confirmed vs pending; avoid masking confirmation state behind eventual consistency silently.
    • Use transactional commits for state writes when correctness is necessary; use eventual consistency for analytics where read-your-writes is not required.

Operational reliability: scaling, observability, and runbooks that save nights

Scaling patterns

  • Partition consumers by block range or by contract address to create independent work streams.
  • For Postgres: use connection pooling (pgbouncer), partition large tables by time or block range, and promote read replicas for heavy reads.
  • For ClickHouse: distribute shards across nodes and use replication; push ingestion into the cluster using the Kafka engine or distributed inserts for high ingestion rates.

Key metrics to track (Prometheus-friendly)

  • indexer_block_height_lag (current_chain_height - last_indexed_block)
  • indexer_event_processing_latency_seconds histogram (micro-batch and single-event)
  • kafka_consumer_lag (partition lag)
  • db_write_errors_total and db_connection_pool_active
  • reorg_count_total and current_reorg_depth

Sample alert rule (example):

alert: IndexerBlockLagHigh
expr: indexer_block_height_lag > 2
for: 5m
labels:
  severity: critical
annotations:
  summary: "Indexer block lag > 2 for 5 minutes"
Enter fullscreen mode Exit fullscreen mode

(Use your product SLAs to choose thresholds; Prometheus docs explain patterns for histograms and alerting.)

Operational runbook snippets

Reorg detected (depth > threshold)

  1. Pause consumer commits or switch to a read‑only mode.
  2. Query block_map to find mismatched block_hash at the depth.
  3. Identify affected tx_hash/log_index ranges and mark those rows as stale or delete from staging.
  4. Reprocess affected block range(s) and reconcile aggregates.
  5. Resume commits and monitor indexer_block_height_lag.

Backfill failure recovery

  1. Inspect worker checkpoints to locate the failing window.
  2. Re-run the single failed window in isolation with tracing enabled.
  3. If data inconsistency exists, run a diff between staging and production and apply compensating transactions.

Runbook fragment (check head lag):

-- postgresql: last indexed block
SELECT MAX(block_number) AS indexed_height FROM events;
-- compare with rpc latest block (via your node or a trusted provider)
Enter fullscreen mode Exit fullscreen mode

Automated safety nets

  • Auto‑scale consumers when kafka_consumer_lag exceeds a threshold.
  • Throttle backfill concurrency when db_write_errors_total spikes.
  • Use circuit breakers to prevent a runaway backfill from saturating RPC quotas.

Practical application: checklists and runbook snippets you can use

Design checklist

  • Identify the critical read paths (list the top 6 API endpoints your users touch).
  • Classify each endpoint as transactional (single-entity state) or analytic (timeline/aggregate).
  • Map transactional endpoints to Postgres schemas and analytic endpoints to ClickHouse schemas.
  • Define confirmation policy per endpoint (confirmation count or unconfirmed flag).

Implementation checklist

  • Build a durable ingestion pipeline: RPC → message bus (Kafka) → consumer workers.
  • Implement micro‑batching with deterministic ordering and idempotent writes.
  • Use composite dedupe keys (tx_hash, log_index) and store block_hash for reorg detection.
  • Create materialized views (Postgres) or precomputed aggregates (ClickHouse) for heavy queries.

Operational checklist

  • Instrument these metrics: block lag, processing latency, consumer lag, DB errors, reorgs.
  • Create alerts with clear thresholds and annotated runbooks.
  • Automate backfill orchestration with checkpointing and idempotent workers.
  • Prepare a schema-swap plan for large rebuilds (write to staging, diff, atomic swap).

Runbook snippet: emergency reindex (high level)

  1. Notify stakeholders and flip API to read-only if needed.
  2. Launch a controlled backfill into events_staging with window=5000, workers=16.
  3. Run a data integrity check (row counts, checksums).
  4. Swap staging tables with production in a transaction or during a maintenance window.
  5. Re-enable writes and watch indexer_block_height_lag and error metrics for 30 minutes.

Sample quick checks

  • Kafka consumer lag: kafka-consumer-groups.sh --bootstrap-server <b> --describe --group indexer
  • Postgres active connections: SELECT COUNT(*) FROM pg_stat_activity WHERE datname = current_database();
  • ClickHouse pending merges: SELECT database, table, total_merges_in_queue FROM system.merges;

Sources:
PostgreSQL Documentation - Reference for ACID transactions, INSERT ... ON CONFLICT upserts, partitioning, materialized views and general Postgres behavior.
ClickHouse Documentation - Details on columnar storage, MergeTree engines (ReplacingMergeTree, CollapsingMergeTree), partitioning, and distributed ingestion patterns.
Apache Kafka Documentation - Streaming semantics, partitions, consumer lag visibility, and best practices for decoupling producers and consumers.
The Graph Documentation - Example of subgraph pattern and how event handlers map on‑chain events to queryable schemas.
Debezium Documentation - Change Data Capture patterns useful for CDC-based incremental indexing and backfill strategies.
Prometheus Documentation - Recommendations for metrics, histograms, and alerting patterns used in operational runbooks.

Apply these patterns deliberately: choose the right store for each query type, make ingestion idempotent and observable, and codify runbooks for the inevitable reorgs and backfills — that combination turns brittle indexers into predictable infrastructure that scales with your dApp.

Top comments (0)