Handling Chain Reorgs in Your Indexer
Here's a bug that never shows up in testing and then quietly poisons your production database: you index a block, write its transactions and events to your DB, and a minute later the chain reorganizes — that block is gone, replaced by a different one. Your DB still has the old transactions. Now you've got phantom transfers, double-counted balances, and events for a block that no longer exists on-chain. Worst part: nothing errors. Your indexer happily keeps going on a corrupted foundation.
Almost every naive indexer has this bug, because the naive mental model — "read block N, store it, move to N+1" — assumes blocks are final the instant you see them. They aren't. Here's how to do it right.
What a reorg actually is
For a brief window, the network can have two competing versions of the chain tip. When a node sees a heavier (more-work or more-attested) branch, it switches to it — discarding ("reorganizing out") the blocks it had and adopting the new ones. Any transactions in the dropped blocks go back to the mempool to be re-included later, possibly in a different order, possibly in a different block.
Reorgs are normal, not exceptional:
- Ethereum L1 (post-Merge): usually 1-block reorgs at the tip, rare but real.
- L2s: the sequencer means no reorgs in normal operation — but sequencer restarts, failovers, or re-derivation from L1 can cause larger, deeper reorgs than you'd expect (see What Is a Sequencer?).
- Higher-throughput PoW/PoS chains (e.g. BSC, some sidechains): reorgs are more frequent and occasionally several blocks deep.
The takeaway: the tip is provisional. Treat any recent block as something that might be revoked.
The root rule: a block number is not an identity
The single most important fix is conceptual. Don't key your data on block number — key on block hash, and track each block's parentHash. Block 21,000,000 can exist twice (two different hashes); only one survives. If your DB says "block 21,000,000 = these 50 txs" by number alone, a reorg silently rewrites history under you.
Store, for every block you index:
{ number, hash, parentHash }
That's what lets you detect a reorg instead of being blindsided by one.
Strategy 1: Confirmation lag (simple, lower latency cost than you think)
The easiest robust approach: don't index the tip. Stay N blocks behind it. Only process block head - N, where N is deep enough that reorgs at that depth are vanishingly unlikely.
const CONFIRMATIONS = 12; // tune per chain
const head = await client.getBlockNumber();
const safeHead = head - BigInt(CONFIRMATIONS);
// index up to safeHead only; never touch blocks above it
Pros: dead simple, no rollback logic. Cons: you're always N blocks behind, so it's not for real-time use cases. Pick N by chain — 12 is a common Ethereum default; high-reorg chains need more; for true safety on Ethereum, anchor to the finalized tag (below) instead of a fixed count.
Strategy 2: Detect and roll back (robust, real-time)
To index at the tip and stay correct, detect reorgs explicitly and undo the damage when one happens. The algorithm on each new block:
- Fetch the new block (with its
parentHash). - Compare its
parentHashto thehashyou have stored for the previous height. - Match → extend normally.
- Mismatch → reorg. Walk backward to find the common ancestor (the highest block where your stored hash equals the chain's current hash at that height), delete everything in your DB above it, and re-index forward from there.
async function indexBlock(client, db, blockNumber) {
const block = await client.getBlock({ blockNumber });
const prev = await db.getBlock(blockNumber - 1n);
if (prev && block.parentHash !== prev.hash) {
// Reorg detected — find common ancestor and roll back
let ancestor = blockNumber - 1n;
while (ancestor > 0n) {
const onChain = await client.getBlock({ blockNumber: ancestor });
const stored = await db.getBlock(ancestor);
if (stored && stored.hash === onChain.hash) break;
ancestor -= 1n;
}
await db.deleteBlocksAbove(ancestor); // revert orphaned data
// re-index from ancestor+1 forward on the next tick
return;
}
await db.upsertBlock({
number: block.number, hash: block.hash, parentHash: block.parentHash,
});
await db.writeEvents(block); // your logs/txs, keyed by (blockHash)
}
The critical companion: make your writes reversible. Store the block hash on every row you write (events, transfers, balances) so a rollback is a clean DELETE ... WHERE block_number > ancestor. If your writes have side effects that can't be undone (firing a webhook, sending an email, crediting a user), gate those behind confirmations — never act irreversibly on an unconfirmed block.
Use the finalized tag as your safety anchor
Ethereum exposes a finalized block tag — blocks at or below it are economically final and cannot reorg. This is your prune line: any block your indexer has at or below finalized is permanent, so you can stop tracking reorg metadata for it and treat it as settled.
const finalized = await client.getBlock({ blockTag: "finalized" });
// everything <= finalized.number is safe; reorg-track only above it
On L2s, finalized follows L1 settlement and lags much further behind the tip — and "soft" pre-confirmations from the sequencer are not the same guarantee. If you need certainty on an L2, anchor to its hard finality, not the sequencer's latest. The distinction is exactly the Soft vs Hard Finality problem.
Gotchas that bite indexers specifically
eth_getLogsstraddling a reorg can return logs that get orphaned, or miss logs that move blocks. Always re-fetch the affected range after a reorg rather than trusting the original result. Tag every stored log with itsblockHash.- WebSocket
newHeadsdelivers reorg'd blocks too. A subscription doesn't shield you — you still get heads on the new branch, and you must checkparentHashcontinuity (see Subscribing with eth_subscribe). - Don't dedupe on
(blockNumber, logIndex)— that pair repeats across competing branches. UseblockHashin the key. - Backfilling deep history is reorg-free, but the moment you catch up to the tip, switch on reorg handling. Many indexers crash here because they never made that transition.
The short version
Reorgs are a normal part of every chain, and an indexer that ignores them is a database that slowly fills with lies. Two fixes, pick per use case:
- Stay behind the tip by N confirmations (or below
finalized) — simple, slightly delayed. - Track
{number, hash, parentHash}, detect parentHash breaks, and roll back to the common ancestor — real-time and correct, as long as your writes are reversible.
Both need a reliable RPC endpoint that won't drop you mid-range or hand you stale heads. A flat-rate Ethereum RPC endpoint gives you finalized/safe block tags, consistent eth_getLogs, and WebSocket heads under one key across dozens of chains. Grab a free key and point your indexer at:
https://rpc.swiftnodes.io/rpc/eth?key=YOUR_API_KEY
Related posts
- The Real Cost of `debug_traceTransaction` (and When to Use It)
Tracing a transaction costs 20-30× more than reading its receipt. Sometimes that's worth it; usually it isn't. Here's what trace actually tells you, the four tracer modes, and when a cheaper RPC method gets the same answer.
- Multicall3 Cheat Sheet: One `eth_call` to Rule Them All
Multicall3 lives at the same address on every EVM chain and turns 100 contract reads into one RPC call. Here's how it actually works, when to use which variant, and the gotchas that bite people in production.
- How `eth_getLogs` Range Caps Bite You in Production
Your indexer works fine until it hits a single dense block range, then everything stops. Here's the cap mechanics across providers, the adaptive chunking pattern that actually works, and the code to do it right.