Resilient EVM Event Indexing: Surviving Chain Reorgs and RPC Throttles

Resilient EVM Event Indexing: Surviving Chain Reorgs and RPC Throttles

Writing Solidity smart contracts is only half the battle in Web3 engineering. The other—often more grueling—half is maintaining accurate off-chain state.

At Nima Labs, our contracts processed thousands of on-chain interactions across Ethereum and Layer-2 rollups. To power user-facing interfaces with sub-second feedback, we couldn’t rely on slow RPC polling or public GraphQL endpoints. We engineered a custom, high-throughput EVM event indexing pipeline in TypeScript.

Here are the engineering strategies we used to handle deep blockchain reorganizations (reorgs), RPC rate limits, and zero-loss state reconciliation.


The Core Problem: Blockchain Is Not a Linear Stream

Web2 developers often assume block headers arrive as a clean, immutable stream of append-only messages. In reality:

  • Micro-reorgs occur constantly (especially on high-speed L2s and Polygon/Arbitrum).
  • Public RPC nodes drop WebSocket connections without firing TCP error events.
  • Blocks can be skipped or delivered out of sequence across multi-node load balancers.
Main Chain:   [Block 100] ---> [Block 101] ---> [Block 102A] ---> [Block 103A] (Orphaned!)
                                      \
Reorg Branch:                          ---> [Block 102B] ---> [Block 103B] ---> [Block 104B] (Canonical)

If your indexer immediately writes database entries on Block 102A without confirmation tracking, your database will end up with “ghost” transactions when the canonical chain switches to 102B.


1. Dual-Phase Finality & Reorg Handling

To maintain transactional consistency, our indexer separates incoming events into two distinct tables:

  1. unfinalized_events: Fast ingestion table tracking recent blocks up to 32 confirmations deep.
  2. canonical_events: Immutable, finalized ledger committed once block depth exceeds network finality.
import { createPublicClient, http, parseAbiItem, Log } from 'viem';
import { mainnet } from 'viem/chains';

interface BlockPointer {
  blockNumber: bigint;
  blockHash: `0x${string}`;
  parentHash: `0x${string}`;
}

export class ReorgResilientIndexer {
  private client = createPublicClient({
    chain: mainnet,
    transport: http(process.env.PRIMARY_RPC_URL),
  });

  private blockHistory: BlockPointer[] = [];
  private readonly FINALITY_DEPTH = 32;

  async processBlock(currentBlockNumber: bigint) {
    const block = await this.client.getBlock({ blockNumber: currentBlockNumber });

    // Check for Chain Reorganization
    if (this.blockHistory.length > 0) {
      const lastKnownBlock = this.blockHistory[this.blockHistory.length - 1];
      
      if (block.parentHash !== lastKnownBlock.blockHash) {
        console.warn(`[REORG DETECTED] Parent hash mismatch at block ${currentBlockNumber}`);
        await this.handleReorg(currentBlockNumber);
        return;
      }
    }

    // Ingest logs
    const logs = await this.client.getLogs({
      fromBlock: currentBlockNumber,
      toBlock: currentBlockNumber,
    });

    await this.persistUnfinalizedLogs(logs, block);
    this.blockHistory.push({
      blockNumber: block.number,
      blockHash: block.hash,
      parentHash: block.parentHash,
    });

    // Prune and promote to canonical
    if (this.blockHistory.length > this.FINALITY_DEPTH) {
      const finalized = this.blockHistory.shift()!;
      await this.promoteToCanonical(finalized.blockNumber);
    }
  }

  private async handleReorg(forkBlockNumber: bigint) {
    // Step backwards through block history until common ancestor is found
    let ancestorFound = false;
    while (!ancestorFound && this.blockHistory.length > 0) {
      const candidate = this.blockHistory.pop()!;
      const actualBlock = await this.client.getBlock({ blockNumber: candidate.blockNumber });
      
      if (actualBlock.hash === candidate.blockHash) {
        ancestorFound = true;
        // Rollback all database state back to this block
        await this.rollbackDatabaseToBlock(candidate.blockNumber);
        break;
      }
    }
  }

  private async persistUnfinalizedLogs(logs: Log[], block: any) { /* DB write logic */ }
  private async promoteToCanonical(blockNumber: bigint) { /* Commit logic */ }
  private async rollbackDatabaseToBlock(blockNumber: bigint) { /* Rollback logic */ }
}

2. Smart Contract Gas Optimization in Solidity

On the smart contract side, minimizing log emissions while maximizing indexing utility requires disciplined event design:

[!TIP] Solidity Best Practice: Index up to 3 indexed topics for topic filtering, but pack scalar types into contiguous uint256 storage slots to minimize EVM memory expansion costs.

// Gas-optimized structured event
event OrderFilled(
    bytes32 indexed orderHash,
    address indexed maker,
    address indexed taker,
    uint128 filledAmount,
    uint128 feePaid
);

By packing filledAmount and feePaid into two 16-byte uint128 values, both fit into a single 32-byte non-indexed data payload slot, reducing gas from 3,250 to 1,870 gas per event emission.


Summary

Building production blockchain systems requires assuming network unreliability by default. By enforcing dual-phase finality checks and building automated reorg rollback machinery, we achieved 99.99% data integrity across hundreds of millions in indexed volume.

DX

Written by DX

Systems Engineer • Focused on high-performance distributed systems, low-level OS internals, and financial engineering.

← Back to all archives