DEV Community

Yuto Nakamura
Yuto Nakamura

Posted on

How EIP-1559 gas estimation actually works under the hood

Most gas estimation libraries give you a single number and call it a day. If you've ever wondered how that number is calculated — or why it's sometimes wrong — here's what's actually happening underneath.

The EIP-1559 fee model

Before EIP-1559, gas pricing was a blind auction. You guessed a price, and miners picked the highest bids. Since EIP-1559 (August 2021), every block has a baseFee set by the protocol and a priorityFee (tip) you choose to incentivize inclusion.

Total fee per gas = baseFee + priorityFee
Enter fullscreen mode Exit fullscreen mode

The baseFee is burned. The priorityFee goes to the validator. You set maxFeePerGas as the maximum you're willing to pay — any difference between your max and the actual baseFee + tip is refunded.

How baseFee changes

The baseFee adjusts every block based on how full the previous block was. The target is 50% utilization:

function estimateNextBaseFee(baseFee, gasUsed, gasLimit) {
  const target = gasLimit / 2n

  if (gasUsed === target) return baseFee  // exactly 50% full — no change

  if (gasUsed > target) {
    // Block was more than 50% full — baseFee goes up
    const delta = gasUsed - target
    const change = baseFee * delta / target / 8n
    return baseFee + (change > 1n ? change : 1n)
  }

  // Block was less than 50% full — baseFee goes down
  const delta = target - gasUsed
  const change = baseFee * delta / target / 8n
  return baseFee - change
}
Enter fullscreen mode Exit fullscreen mode

The key number is 8. The baseFee can change by at most 12.5% per block (1/8). This means:

  • A completely full block (100% gas used) increases baseFee by 12.5%
  • An empty block (0% gas used) decreases it by 12.5%
  • A half-full block keeps it the same

This is the formula every estimator should use. Libraries that just read eth_gasPrice and add a buffer are ignoring half the picture.

Getting historical data

The RPC method eth_feeHistory returns baseFee and priorityFee data for recent blocks:

const history = await rpc('eth_feeHistory', [
  '0x14',      // 20 blocks
  'latest',    // newest block
  [10, 25, 50, 75]  // priority fee percentiles we want
])

// Returns:
// baseFeePerGas: [block_n, block_n+1, ..., block_n+21]  (one extra for next block estimate)
// reward: [[p10, p25, p50, p75], ...]  per-block priority fees at each percentile
// gasUsedRatio: [0.45, 0.67, ...]  how full each block was
Enter fullscreen mode Exit fullscreen mode

The extra baseFee at the end of the array is the protocol's own estimate of the next block's baseFee. But I found it's more accurate to calculate it yourself from the latest block's gasUsed ratio.

Building speed tiers

Here's where it gets practical. A "slow" transaction needs a lower priority fee than a "fast" one. The question is: how much lower?

The approach that works: take the priority fee percentiles from the last 20 blocks and use them as tiers.

10th percentile → slow     (bottom 10% of tips still got included)
25th percentile → standard (bottom quarter)
50th percentile → fast     (median tip)
75th percentile → instant  (top quarter)
Enter fullscreen mode Exit fullscreen mode

But you also need to adjust maxFeePerGas per tier. A "slow" transaction can use a tight maxFee (just above current baseFee), while "instant" should buffer for baseFee increases:

const slow = {
  maxPriorityFeePerGas: percentile10,
  maxFeePerGas: nextBaseFee + percentile10,  // tight — no buffer
  estimatedSeconds: blockTime * 3,
}

const instant = {
  maxPriorityFeePerGas: percentile75,
  maxFeePerGas: nextBaseFee * 150n / 100n + percentile75,  // 50% baseFee buffer
  estimatedSeconds: Math.max(1, blockTime / 2),
}
Enter fullscreen mode Exit fullscreen mode

The 150% baseFee buffer on "instant" means even if the next few blocks are completely full and baseFee spikes, your transaction still gets in. For "slow", you're betting baseFee stays roughly flat — if it rises, your transaction waits longer.

The monotonic ordering problem

On some chains (notably Avalanche), the priority fee percentiles don't increase monotonically. The 50th percentile can be lower than the 25th if there are weird fee distributions in recent blocks.

This breaks user expectations. If "fast" shows a lower fee than "standard", people lose trust in the estimator. The fix is simple — enforce ordering after computing the raw percentiles:

if (standard.maxFeePerGas < slow.maxFeePerGas) {
  standard.maxFeePerGas = slow.maxFeePerGas
}
if (fast.maxFeePerGas < standard.maxFeePerGas) {
  fast.maxFeePerGas = standard.maxFeePerGas + 1n
}
if (instant.maxFeePerGas < fast.maxFeePerGas) {
  instant.maxFeePerGas = fast.maxFeePerGas + 1n
}
Enter fullscreen mode Exit fullscreen mode

I didn't see any existing library handle this. Most return raw percentiles and let the consumer deal with inversions.

Why MetaMask sometimes overestimates

MetaMask uses its own gas fee controller with a similar approach, but it has access to a proprietary API that analyzes the mempool. For most developers building their own applications, you don't have access to that API.

The good news: for 95% of use cases, eth_feeHistory percentiles are accurate enough. The mempool analysis matters most during extreme congestion — gas wars, popular NFT mints, etc. For normal transactions, historical percentiles predict the next few blocks well.

Confidence intervals

One feature I found missing everywhere: telling the user how confident the estimate is.

If baseFee has been stable for the last 50 blocks, the estimate is highly reliable. If it's been spiking wildly, the same median number could be off by 3x.

The implementation is straightforward — compute the spread between the 5th and 95th percentile of recent baseFees and express it as a confidence score:

const spread = (p95 - p5) / p50  // relative spread
const confidence = Math.round((1 - spread) * 100)
// 95 = very stable, estimate is reliable
// 40 = volatile, estimate is a rough guide
Enter fullscreen mode Exit fullscreen mode

This gives the consumer a way to decide: at 90% confidence, auto-submit. At 40% confidence, show a warning and let the user adjust.

L2 cost: the hidden fee

On L2 chains (Optimism, Base, Arbitrum), the gas price you see is only half the story. Every L2 transaction also pays an L1 data fee — the cost of posting your transaction data to Ethereum mainnet for security.

For a typical ERC-20 transfer:

  • L2 execution fee: ~0.000001 ETH
  • L1 data fee: ~0.000050 ETH

The L1 data fee is 50x the L2 execution fee. If your estimator only shows L2 gas, your users think the transaction costs almost nothing, then get surprised by the actual cost.

On OP Stack chains (Optimism, Base), you can query the L1 data fee from a predeployed contract:

const GAS_ORACLE = '0x420000000000000000000000000000000000000F'
const l1Fee = await ethCall(GAS_ORACLE, getL1Fee(txData))
Enter fullscreen mode Exit fullscreen mode

On Arbitrum, the mechanism is different — you query ArbGasInfo at 0x06C for the L1 base fee estimate and calculate from calldata size.

What I built

I packaged all of this into a standalone gas oracle with zero dependencies. Four speed tiers, confidence intervals, trend analysis, L2 cost separation, 15 chains out of the box.

The motivation was straightforward: every existing gas oracle package on npm is either dead (last updated 2021-2022), locked into a specific ecosystem (MetaMask's controller needs 12 internal dependencies), or requires an external API key.

If you're building a wallet, a trading bot, or any dApp that sends transactions, the gas price matters. Getting it wrong means either wasted money or stuck transactions.


I'm Yuto — I build developer tools for the crypto ecosystem at pulsadev.dev. The gas oracle is at @pulsadev/gas-oracle, along with packages for multicall, ABI encoding, and transaction decoding.

Top comments (0)