Your indexer credited a deposit. Seconds later its block was replaced by a different block at the same height, and the deposit isn't in it. Your database now describes a chain that no longer exists. That's a reorg, and any backend that reads from the chain tip has to plan for it.
Why reorgs happen
Near the tip of the chain, nodes can briefly disagree about which block is canonical: two valid blocks at the same height, network latency, a validator proposing late. Fork choice resolves it, and nodes on the losing branch switch over. Blocks on the losing branch are dropped, and transactions in them either reappear in a later block (possibly in a different order or with a different outcome) or disappear entirely, for example if a conflicting transaction with the same nonce landed first.
How deep reorgs go depends on the chain's consensus. On post-merge Ethereum, shallow reorgs of a block or so happen occasionally, and blocks become finalized after a couple of epochs. Polygon PoS historically saw deeper reorgs than Ethereum mainnet, and its finality mechanism has changed over time. The robust approach is the same everywhere: don't hard-code a depth, and don't assume a block is permanent until the chain says it is.
Use the chain's own finality signals
Most EVM clients support block tags beyond latest:
| Tag | Meaning |
|---|---|
latest | The current head. Can be reorged. |
safe | Unlikely to be reorged under normal conditions. |
finalized | Can't be reverted without the chain's finality guarantees breaking. |
Support for safe and finalized varies by chain, client and RPC provider. Call them against your provider and confirm they return what you expect before you depend on them.
The simplest correct design is: only take irreversible actions on finalized data. Show a deposit as "pending" as soon as you see it at latest, but credit balances, release goods or trigger withdrawals only once its block is at or below finalized.
Detect reorgs by tracking block hashes
Block numbers aren't identifiers; after a reorg, block 1000 is a different block. Store the hash of every block you index and check that each new block's parentHash equals the hash you stored for the previous number. If it doesn't, you've found a reorg. Walk back until your stored hash matches the canonical hash at that height (the common ancestor), delete everything above it, and re-index forward.
A schema that makes rollback easy ties every derived row to the block it came from:
CREATE TABLE blocks (
number BIGINT PRIMARY KEY,
hash TEXT NOT NULL UNIQUE,
parent_hash TEXT NOT NULL
);
CREATE TABLE transfers (
block_number BIGINT NOT NULL REFERENCES blocks(number) ON DELETE CASCADE,
log_index INTEGER NOT NULL,
tx_hash TEXT NOT NULL,
from_addr TEXT NOT NULL,
to_addr TEXT NOT NULL,
value NUMERIC NOT NULL,
PRIMARY KEY (block_number, log_index)
);
Deleting a block row cascades to its events. Anything you aggregate from events, such as balances or totals, must be recomputable from those rows or updated with reversible deltas. An aggregate you can't roll back is where reorg bugs hide.
An indexing loop with viem
import { createPublicClient, http, parseAbiItem, type Address, type Hash, type Log } from "viem";
import { polygon } from "viem/chains";
const client = createPublicClient({ chain: polygon, transport: http(process.env.RPC_URL) });
const transferEvent = parseAbiItem(
"event Transfer(address indexed from, address indexed to, uint256 value)",
);
type StoredBlock = { number: bigint; hash: Hash };
interface Db {
lastBlock(): Promise<StoredBlock | null>;
deleteFrom(blockNumber: bigint): Promise<void>; // delete blocks >= n (cascades)
saveBlock(block: StoredBlock & { parentHash: Hash }, logs: Log[]): Promise<void>; // one DB transaction
}
export async function indexStep(db: Db, token: Address, startBlock: bigint, batch = 50n) {
let last = await db.lastBlock();
// 1. Rewind until our latest stored block is still canonical.
while (last) {
const canonical = await client.getBlock({ blockNumber: last.number });
if (canonical.hash === last.hash) break;
await db.deleteFrom(last.number);
last = await db.lastBlock();
}
// 2. Index forward, verifying the parent link for every block.
const head = await client.getBlockNumber();
const from = last ? last.number + 1n : startBlock;
const to = head < from + batch - 1n ? head : from + batch - 1n;
for (let n = from; n <= to; n++) {
const block = await client.getBlock({ blockNumber: n });
if (last && block.parentHash !== last.hash) return; // reorg mid-batch; next step rewinds
// Query by block hash so the logs belong to exactly this block.
const logs = await client.getLogs({ address: token, event: transferEvent, blockHash: block.hash });
await db.saveBlock({ number: block.number, hash: block.hash, parentHash: block.parentHash }, logs);
last = { number: block.number, hash: block.hash };
}
}
Two details matter here. First, getLogs with blockHash instead of a number range guarantees the logs come from the block you just verified, not from a replacement that appeared between two RPC calls. Second, each block and its events are saved in one database transaction, so a crash can't leave a block without its events or the reverse.
Call indexStep in a loop. Periodically read the finalized block number too: anything at or below it should never need rewinding, so alert loudly if a rewind ever tries to go below that height. It means something is wrong with your node or provider.
If you use subscriptions
WebSocket log subscriptions (eth_subscribe with logs) re-send logs from dropped blocks with removed: true. If you consume them, handle removed explicitly by deleting what that log created. Subscriptions also silently miss events when the connection drops, so run a hash-checked backfill like the loop above as the source of truth and treat subscriptions as a latency optimization.
Make side effects idempotent
Reorgs mean you'll sometimes process the same transaction twice: once in the dropped block and again in its new block. Key external effects on something stable, like (tx_hash, log_index), and check before acting. Don't key them on block number. And remember a re-included transaction can revert in its new position even though it succeeded before, so re-read the receipt rather than assuming the old result.
Checklist
- Store block hashes and verify
parentHashcontinuity on every block. - On mismatch, walk back to the common ancestor, delete, and re-index.
- Fetch logs by
blockHashand write each block atomically. - Keep aggregates recomputable or reversible.
- Show
latestdata as pending; take irreversible actions only atfinalized. - Handle
removed: truein subscriptions, and backfill as the source of truth. - Make side effects idempotent on
(tx_hash, log_index).
Get the weekly commit
New blockchain deep dives every week.