The Call That Reverts at the Last Step
You add the 20% buffer. You always add the 20% buffer. The estimator returned 180,000 gas, you submitted at 216,000, and you are now watching the transaction fail at 214,000 gas consumed, just shy of your own limit. Out of gas. Funds temporarily stuck. Reason: not immediately obvious, which is the part that should bother you.
Gas estimation undercounting isn't random. It follows patterns rooted in how Ethereum's execution environment actually works, and those patterns are consistent enough that once you understand them, you stop being surprised by the failures and start being surprised you ever trusted the number in the first place.
How Estimation Actually Works (and Where the Gap Opens)
When a wallet or dApp calls `eth_estimateGas`, the node runs a binary search. It simulates the transaction at increasing gas limits until it finds the lowest value that doesn't revert, then returns that number. The simulation runs against the current state of the chain at the moment of the call.
That last phrase is the first crack in the foundation.
The simulation is a snapshot. By the time your transaction lands in a block, seconds or minutes later, state may have shifted. Storage slots that were warm (already accessed in the same block, costing 100 gas to re-read) are cold again (costing 2,100 gas to access for the first time in a transaction). Another transaction may have written to a slot your contract reads, changing a branching condition entirely.
Here's a concrete scenario with real numbers. Suppose a lending protocol has a function that checks whether a user's health factor is above 1.0. During estimation, the health factor is 1.05. The estimator takes the cheaper branch: no liquidation check, no extra storage reads, 160,000 gas consumed. Forty seconds later, an oracle price update lands first in the block. The health factor is now 0.98. Your transaction takes the liquidation branch, reads four additional storage slots cold, and burns 248,000 gas. Your limit of 192,000 wasn't close.
This is the branching problem. The estimator only sees one path through the code: the path that exists at estimation time. Every conditional in your contract is a place where the estimate and reality can silently diverge.
Three Specific Mechanisms That Quietly Inflate Real Cost
Cold Storage Slots After EIP-2929
EIP-2929, activated in the Berlin upgrade, raised the cost of accessing a storage slot for the first time in a transaction from 800 gas to 2,100 gas. The intent was to price denial-of-service vectors more accurately. The side effect: estimation errors involving cold-versus-warm storage became dramatically more expensive.
A contract that reads ten unique storage slots pays 21,000 gas in access costs alone if all are cold. If the estimator runs its simulation inside a context where some slots were already touched (say, a preceding simulation call in the same JSON-RPC batch), those slots appear warm. The real transaction, arriving in a fresh block, pays full cold prices. The delta on ten slots is 20,000 gas. Not catastrophic. Enough to tip a tight limit into a revert.
Dynamic Dispatch and Unknown Call Targets
Proxy patterns are everywhere in deployed DeFi. A call to a proxy contract resolves to an implementation address stored in a slot. If the estimator reads implementation address A but a governance transaction upgrades the proxy to implementation B between estimation and execution, the entire call graph changes. A completely different function body executes. The estimator's gas figure is now describing a transaction that will never happen.
Even without upgrades, external calls to user-supplied addresses are a known hazard. A function that calls an arbitrary ERC-20 token's `transfer` method doesn't know in advance whether that token has a 30-line implementation or a 300-line one with hooks, callbacks, and its own storage writes. The estimator simulates against whatever token address is passed at that moment. Pass a different token with heavier logic, and the estimate is wrong by design. This isn't an edge case; it's a category of failure that proxy-heavy protocols encounter regularly.
Refund Arithmetic That No Longer Saves You
Prior to EIP-3529 (London upgrade), contracts could accumulate large gas refunds by zeroing storage slots, up to half the transaction's total gas cost. Some estimation logic implicitly assumed those refunds would offset real execution costs, producing estimates that looked safe on paper because the net cost after refund would be fine.
EIP-3529 capped refunds at one-fifth of gas used and eliminated `SELFDESTRUCT` refunds entirely. Contracts and tooling built before London that hadn't been updated were suddenly underestimating costs for any path that relied on refund-heavy cleanup logic. The pattern still appears in audits of older protocol forks, which tells you something about how rarely teams revisit their gas assumptions.
The Buffer Isn't a Fix, It's a Guess
The industry-standard advice is to add 10-20% to the estimate. It works often enough that it feels like a solution.
It isn't.
Take two developers integrating the same DEX aggregator. Maya adds 15% and deploys to a low-traffic testnet where block times are predictable and state rarely shifts between estimation and execution. Works every time. Rodrigo deploys the same integration to mainnet during a period of heavy MEV activity, where block ordering is aggressive and oracle updates land frequently. Rodrigo starts seeing 0.3% of transactions fail with out-of-gas errors. Small enough to ignore, until it isn't, because those failures cluster around exactly the moments of high price volatility when the transactions matter most. The buffer is a blindfold, not a model.
Ask yourself: if you don't know what causes your estimate to be wrong, how do you know 20% is the right correction?
Honest Limits of the Estimation Model
The Ethereum Yellow Paper doesn't specify how `eth_estimateGas` must behave. Different clients (Geth, Nethermind, Besu) implement the binary search with slightly different assumptions about the starting state. Some include the transaction's own access list in the warm-slot calculation during simulation; some don't. The same contract call, estimated by two different node implementations, can return figures that differ by thousands of gas. That's not a bug in one client. It's the spec leaving room for interpretation.
There's also a subtler issue. The estimator cannot simulate validator ordering decisions. Flashbots bundles, private mempools, and MEV-boost relays all affect which transactions precede yours in a block. Any of those preceding transactions can touch storage your contract reads. The estimator has no model for that. It can't, in the same way a weather forecast can't account for the specific butterfly.
For contracts with complex branching logic or external calls to unknown addresses, the honest engineering answer is to estimate at a higher-than-expected gas limit and let the EVM return unused gas. The cost of unused gas is zero. The cost of a failed transaction includes the gas consumed up to the failure point (not refunded), plus whatever downstream state inconsistency the partial execution left behind.
What Actually Helps
Simulate across multiple state snapshots, not just the current one. Foundry's `forge`, to take the clearest example, lets you fork mainnet at a specific block and run the same transaction against several recent blocks to see how gas consumption varies with state. Wide variance means your buffer needs to match. If consumption swings between 160,000 and 248,000 depending on which block you fork, a 20% buffer is a coin flip.
Use access lists (EIP-2930) for contracts with known, predictable storage access patterns. Pre-declaring which slots a transaction will touch lets the EVM pre-warm them at a predictable cost, narrowing the gap between estimated and actual gas. It requires more work upfront. It's worth it.
For production integrations, log every transaction's actual gas used against its estimate. The ratio drifts over time as contract state grows and as upgrades change call paths. A ratio stable at 1.05 that drifts to 1.18 is a signal, not noise. Treat it like one.
Gas estimation is a reasonable approximation of a moving target, and the tooling industry has done a decent job of pretending otherwise. The EVM will always know more about what your transaction actually does than any simulation run sixty seconds before it executes. Building systems that assume the estimate is authoritative is the real mistake, and it's a remarkably common one for a problem this well-documented.