2026-11-05
How Fast Is Polygon Finality? We Cut Our Wait From 150 Blocks to Zero

Polygon's docs say milestones typically finalize a transaction within 2 to 5 seconds. Before the Heimdall v2 upgrade in July 2025, finality took 1 to 2 minutes.
Until last month Klap ignored all of that and waited 150 blocks before crediting a Polygon payment, which is about 5 minutes. BNB Chain got 70 blocks, about 30 seconds. On October 26 we moved both to zero blocks on top of a finalized block. A Polygon payment now usually confirms in 4 to 8 seconds, and a BNB Chain payment in 1 to 4.
The change itself is small. Most of the work was making sure "finalized" meant what we needed it to mean.
What finality rests on
On Polygon, each validator reports the Bor block hashes it has seen since the last milestone. Heimdall picks the longest sequence backed by at least two thirds of validators, and that becomes the new milestone. Blocks at or below it are what the RPC returns for the finalized tag, and Polygon's docs call them irreversible.
BNB Chain gets there differently. Under BEP-126, a block is justified once a supermajority of validators votes for it, and finalized once its direct child is justified too. That means block n is usually final by n + 2, around a second later. Validators who break the voting rules can be slashed.
So on both chains we stopped asking how many blocks make a reorg unlikely. The protocol now gives an answer, and the old margin wasn't buying much. It did leave Polygon customers watching a progress bar for up to five minutes.
Why zero blocks needs more checks
Our depth check is currentBlock - transferBlock >= depth. At depth 0 that only says the node claims to have a block at that height, so all the safety has to come from what currentBlock is.
The obvious part is reading the finalized tag instead of latest, with no caching. With viem, simplified, that looks like this:
const [latest, finalized] = await Promise.all([client.getBlock({ blockTag: 'latest' }),client.getBlock({ blockTag: 'finalized' }),])if (finalized.number >= latest.number) {throw new Error('finalized head is not trailing latest')}const isFinal = transfer.blockNumber <= finalized.number
Two more guards came from how these chains differ from Arc, which we had already moved to finalized blocks. On Arc the two tags are the same block. On Polygon and BNB Chain finality trails the tip, and those last few blocks can still be replaced, so we couldn't just reuse the Arc path.
The finalized block has to really trail the tip. A provider that maps finalized to the newest block would turn every credit into an early one, and from the outside it would look perfectly healthy. That's what the throw above is for. BEP-126 can't finalize a block before votes on its child exist, so on BNB Chain this never happens honestly. On Polygon it does happen occasionally, about 1 in 250 samples when we measured. We skip that check cycle and try again 2 seconds later. The credit arrives a little late, never early.
The transfer's own block has to be canonical. A receipt can come from a short-lived fork, or from a lagging backend behind a load-balanced URL. So before crediting we read the block at the transfer's height and compare its hash with the one recorded on the transfer:
const canonical = await client.getBlock({ blockNumber: transfer.blockNumber })const canCredit = isFinal && canonical.hash === transfer.blockHash
If they differ, we don't credit. The transfer stays recorded as a sighting, and a recheck that runs every 2 seconds credits it from a fresh receipt once it's really on the finalized chain. A transfer with no recorded block hash never gets credited on these networks.
What the RPC endpoints showed
We didn't want to assume an endpoint implements finalized correctly, so we wrote a probe and sampled each one 250 times over 15 minutes. It compares latest and finalized against what the protocol itself reports: Heimdall's latest milestone on Polygon and eth_getFinalizedHeader on BNB Chain.
| Endpoint | Finalized lag behind latest | Moved backwards | Ahead of the protocol |
|---|---|---|---|
| Polygon, the one we use | 1 to 4 blocks (3 to 6 s) | Never | Never |
| BNB Chain, the one we use | 1 to 4 blocks (1 to 2 s) | Never | Never |
| BNB Chain, a public endpoint we don't use | Not measured | 4 times | Up to 13 blocks |
That third row is why the probe is worth running. Its finalized block moved backwards four times in 15 minutes, which a finalized block can't do. Our best explanation is several backends behind one URL that don't agree with each other, which is the case the block-hash check was built for. A plain read of that endpoint would have looked fine most of the time.
If you're doing this yourself, sampling for a few minutes will tell you more than a provider's docs. An endpoint should fail if finalized goes backwards, changes hash at the same height, gets ahead of the protocol's own answer, or equals latest every time. We now require every Polygon and BNB Chain endpoint to pass before it can serve credits.
What happens when finality stalls
This is the cost of the change, and it has already happened once. On September 10, 2025, a bug in Polygon's milestone processing delayed finalization. Blocks kept coming, and it took a Heimdall hard fork that same day to fully restore milestones, about 10 hours after the first report.
If that happens again, Klap will stop crediting Polygon payments until finality recovers. A count of 150 blocks would have kept moving during that outage, and we think stopping is the right call anyway: the protocol is saying it can't vouch for those blocks. But it has two effects you should know about:
- A customer's payment can sit unconfirmed for as long as the stall lasts, even though it's visible on a block explorer.
- A charge can expire in the meantime. Klap reconciles the transfer automatically within an hour of expiry, and by hand after that, so your order flow needs to handle a payment arriving on an expired charge.
We log a warning when the finalized block falls 60 blocks behind on Polygon (about 2 minutes) or 130 on BNB Chain (about a minute).
Safety on these two networks now rests on the protocol's own finality instead of an N-block margin we chose: two thirds of Heimdall stake on Polygon, and two thirds of the validator set on BNB Chain.
What changes for you
Nothing in your integration. Charges on Polygon and BNB Chain go from detected to confirmed faster, and the charge.confirmed webhook means what it always meant.
If you built your own checkout, the one visible change is that the confirmation_progress event no longer fires on these two networks, the same as on Arc, because there's no block count left to show. A UI that waits for that event before moving on would get stuck, so drive it from the charge event instead. The confirmations post covers how our own checkout handles it, and USDC vs USDT for merchants compares the wait on every network we support.