Every smart contract engineer eventually hits the same wall.
You've deployed your contract. Events are firing on-chain. Now your backend needs to know about them — reliably, in order, without missing anything, and without acting twice on the same event. You start reaching for an off-the-shelf indexing solution, read the documentation, check the pricing, and do a quiet double-take at what 24/7 block-level monitoring costs when your RPC provider charges per request.
That was the moment I decided to build something instead.
What came out of that decision was a custom blockchain indexer: a Node.js service that listens to any EVM-compatible chain, subscribes to specific contract events, handles re-orgs, self-heals through a reconciliation job, and delivers confirmed events to downstream services via BullMQ. It ran in production against Avalanche and cost a fraction of what the standard approach would have.
This is the architecture — and more importantly, the reasoning behind it.
The Two Standard Patterns (and Why Both Hurt)
Before building anything custom, I mapped out how blockchain indexers typically work. Two patterns dominate.
Pattern 1: Listen to Every Block
The gold standard approach. You open a WebSocket subscription to the node, receive a notification for every new block, fetch the block's event logs via HTTP, check whether any of your subscribed events appeared, and if so, handle them after waiting for the safe confirmation depth to guard against re-orgs.
It's reliable. It's complete. And it runs 24/7, consuming RPC credits continuously regardless of whether any of your events are actually firing.
Here's what that costs on Avalanche — which produces a block roughly every two seconds:
For Ethereum with ~ 6,500 blocks/day, the baseline is lower (~$6/month) but still constant and growing. The problem isn't the absolute dollar amount — it's that you're paying that regardless of your contract's activity. A low-traffic contract on a high-throughput chain pays the same infrastructure cost as a high-traffic one.
Pattern 2: Subscribe to Events Only, Then Poll for Safety
The lighter alternative: skip the block subscription entirely and use the node's event filter subscription. Instead of watching every block, you only receive a notification when one of your specific events fires. Far fewer notifications. Far lower baseline cost.
The catch is re-org safety. When an event notification arrives, the block it landed on isn't necessarily final — a chain re-organization could still reorganize that block out of existence. The standard mitigation is to start polling after each event: wait until the current chain tip is N blocks beyond your event's block, then confirm it's safe to process.
The problem here is that polling-per-event creates a tail of HTTP calls for every event received, and the logic for managing concurrent per-event polling windows gets messy quickly. If your contract emits bursts of events, you're running many polling loops simultaneously with no coordination.
Neither pattern fit what we were building — a multi-contract, multi-chain listener that needed to be operationally lean and cost-predictable.
The Hybrid Architecture
The approach I landed on combines the best of both patterns without the worst of either.
The core insight: use event subscriptions for low-cost real-time detection, and a periodic batch confirmation job for safe processing — decoupling the moment of detection from the moment of delivery.
Here's the full picture:
Three independent components, each with a single responsibility.
Component 1: The Event Listener
The listener uses both a WebSocket provider and an HTTP provider — WSS for real-time event subscriptions, HTTP for confirmation and reconciliation queries.
Reliable WSS connections to blockchain nodes require more care than a standard WebSocket client. Nodes drop connections silently. Providers rotate endpoints. The event loop stays alive but the subscription is dead — a failure mode that looks exactly like "no events firing" until you realize your last received event was six hours ago.
The setup included:
The WSS listener's job is narrow: detect events as fast as possible and persist them with status = 'pending'. Nothing is confirmed here. Nothing is delivered here. The listener doesn't know or care about re-orgs — that's the confirmation job's problem.
The ON CONFLICT DO NOTHING on the insert is critical. The reconciliation job may write the same event independently of the listener. The unique constraint on (tx_hash, log_index) ensures the event lands in the DB exactly once regardless of which component finds it first — idempotency at the storage layer rather than the application layer.
Component 2: The Confirmation Job
The confirmation job runs on a schedule — every few minutes — and processes all pending events in a batch. For each pending event, it fetches the transaction receipt from the chain and makes two checks.
Check 1: Re-org detection.
A re-org happens when the block your event landed on is reorganized out of the canonical chain. Your event may still exist — but on a different block number than where you first saw it.
The re-org handling is elegant precisely because of the deferred processing model. When a re-org is detected, the event isn't discarded — its block number is updated and it stays pending. On the next confirmation cycle, the check runs again with the corrected block number. The event eventually confirms on whichever block it actually landed on, or gets cleaned up if the transaction was dropped entirely.
Set REQUIRED_CONFIRMATIONS based on your chain's finality characteristics. Ethereum with proof-of-stake has fast finality but typically uses 12–64 confirmations for safety. Avalanche's Snowball consensus is much faster — 1–3 confirmations is often sufficient. Using Ethereum's confirmation depth on Avalanche adds unnecessary latency; using Avalanche's depth on Ethereum is unsafe.
Component 3: The Reconciliation Job
The WSS listener is reliable under normal conditions. It's not reliable under all conditions — a brief network interruption, a provider-side restart, or a silent WSS disconnection in the seconds before the heartbeat fires can cause events to be missed.
The reconciliation job is the fallback. It runs independently of the listener and asks a simple question: have any events occurred in the block range we haven't seen yet?
Events found by the reconciliation job are written as confirmed directly — they're in the past, the current chain tip is already well beyond them, re-org risk is negligible. The confirmation job doesn't need to touch them.
The cost profile of this job is minimal: one getBlockNumber HTTP call per run, then queryFilter calls only when there's an actual gap between last indexed and current block. When the listener is healthy and processing events in real time, the reconciliation gap stays near zero and the job costs almost nothing.
The Cost Math
Here's what the shift from block-subscription to event-subscription actually means for the RPC bill.
Block-by-block approach on Avalanche (~43,200 blocks/day):
| Operation | Credits/call | Calls/day | Credits/day |
|---|---|---|---|
| WSS block notifications | 100 | 43,200 | 4,320,000 |
| HTTP getLogs per block | 200 | 43,200 | 8,640,000 |
| Total | ~12.96M/day |
Monthly: ~388M credits ≈ $38–$40/month per chain at baseline, before any events
Event-first approach (same chain, same contracts):
| Operation | Credits/call | Calls/day | Credits/day |
|---|---|---|---|
| WSS event notifications | 100 | ~contract activity | Variable |
| Reconciliation getBlockNumber | 200 | 288 (every 5 min) | 57,600 |
| Confirmation getReceipt | 200 | ~per pending event | Variable |
| Baseline (no events) | ~57,600/day |
Monthly baseline: ~1.7M credits ≈ < $0.20/month
Even at high contract activity — say 5,000 events per day triggering receipts and WSS notifications — the total is still under 2M credits/day, roughly $6/month. Against $38/month for idle block watching, the savings compound every month and grow as the gap between "blocks produced" and "events we care about" widens.
The savings are proportional to how sparse your events are relative to the chain's block frequency. On a slow chain with high-traffic contracts, the gap narrows. On a fast chain (Avalanche, Base, Polygon) with infrequent events, the gap is enormous. Audit your expected event frequency against the chain's block rate before choosing an indexing pattern.
Delivery: BullMQ Over Webhooks
The system had both a BullMQ queue option and a webhook option for delivering confirmed events to the recipient service. We used BullMQ internally.
The reasoning was the same as any queue-vs-webhook decision at this layer:
- Retries: webhook delivery failure means the event is lost unless you build retry logic yourself. BullMQ retries with backoff are built-in.
- Idempotency: the BullMQ
jobIdkeyed ontx_hash:log_indexmeans the same event can never be enqueued twice, regardless of how many times the confirmation or reconciliation job encounters it. - Backpressure: if the recipient service is slow or down, BullMQ queues events without dropping them. A webhook would fail immediately and require you to implement your own buffering.
- Observability: BullBoard gives a live view of event queue depth, retry state, and dead letter items — the same setup from the earlier worker architecture.
The webhook option remained available for external recipient services that couldn't connect to the internal queue. For those, the delivery layer was a thin BullMQ worker that read confirmed events and issued the HTTP POST — so BullMQ's guarantees still applied internally; the webhook was just the final transport.
Production Notes: Sepolia and Avalanche
Testing against Sepolia first paid off. Sepolia has frequent re-orgs relative to mainnet — an excellent environment for validating the re-org detection path without real money on the line.
The re-org detection code handled block shifts cleanly: the pending event updates its block number, waits a cycle, and re-evaluates. During Sepolia testing we saw several re-orgs complete and re-confirm within two confirmation cycles. The recipient service received the event once, at the correct final block number. No duplicates, no dropped events.
Avalanche production revealed one additional tuning requirement: the reconciliation chunk size. Avalanche's fast block time means a 10-minute reconciliation gap can accumulate thousands of blocks. Querying getLogs over a 5,000-block range in a single call reliably hit response size limits. Chunking at 500 blocks per query eliminated those failures.
What This Is and Isn't
This indexer is not a replacement for The Graph, Alchemy's webhooks, or Moralis for teams that want a managed solution. Those products exist, they're well-built, and if the economics work for your use case they're worth using.
What this is: a purpose-built indexer for a specific set of contracts and events, where the operational model demanded low baseline RPC cost, full control over confirmation logic, and tight integration with an existing internal queue infrastructure.
The architectural pattern — event subscription for detection, batch job for confirmation, range query for reconciliation — is broadly applicable. It doesn't require a custom implementation; the same structure works whether you're indexing a single NFT contract or a suite of DeFi protocols across multiple chains.
The reconciliation job in particular is the piece most implementations skip. It's also the piece that makes the whole system production-grade rather than demo-grade. Real-time detection is the happy path. The reconciliation job is what makes the happy path the only path.
The bill is what triggered the build. The build turned out to solve a problem worth solving beyond the bill.
A note on the code and numbers in this article. The implementation snippets throughout this post are simplified and reconstructed for clarity — they're meant to illustrate the architectural patterns and reasoning, not serve as production-ready code. Error handling, provider configuration, chain-specific quirks, and operational details have been condensed or omitted for readability. Similarly, the RPC cost estimations are approximations based on publicly available pricing tiers at the time of writing; actual costs will vary depending on your provider, plan, event volume, and the chains you're targeting. Treat the numbers as directional signals, not billing forecasts. Always benchmark against your own contract's activity profile before making infrastructure decisions.
