Estimating Gas Right: eth_estimateGas, EIP-1559, and Buffers

July 3, 2026 · 6 min read · #gas #eth_estimateGas #eip-1559 #ethereum #tutorial

Two of the most common ways a transaction fails have nothing to do with your contract logic: it runs out of gas mid-execution, or its fee is too low to ever get included. Both come down to gas estimation — and the reason people get it wrong is that "gas" actually answers two completely separate questions. Get the mental model right and the RPC calls fall into place.

Gas is two things, not one

When you send a transaction you're specifying:

  • How much computation it may use — the gas limit, measured in gas units. Every EVM operation costs a fixed number of units (a simple transfer is 21,000; a storage write is thousands more). This is what eth_estimateGas predicts.
  • How much you'll pay per unit — the price, denominated in wei. Post-EIP-1559 this splits into a base fee (set by the protocol, burned) and a priority fee / tip (goes to the validator).

Your total cost is gasUsed × (baseFee + tip). Conflating the two — treating one estimate as "the gas" — is where the trouble starts. You need to get both right, and they come from different RPC methods.

eth_estimateGas: the units side

eth_estimateGas takes a transaction object and simulates it against current state, returning the gas units it would consume. It's the same execution as eth_call (see Calling a Contract at a Historical Block), but instead of returning the result it returns the gas.

curl -s -X POST https://rpc.swiftnodes.io/rpc/eth?key=YOUR_API_KEY \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","method":"eth_estimateGas",
       "params":[{"from":"0xYourAddr","to":"0xContract","data":"0xa9059cbb…"}],
       "id":1}'
# -> {"result":"0xcf08"}   (0xcf08 = 53,000 gas units)

Three things to know about that number:

  • It's a simulation at current state. The estimate reflects the chain right now. By the time your transaction is mined, storage may have changed — and a different execution branch can cost more (or less) gas.
  • If the transaction would revert, the estimate errors. eth_estimateGas returns an error (often with the revert reason) rather than a number. That's actually useful — it's a free pre-flight check that catches a doomed transaction before you spend anything.
  • It can under-estimate. The classic case: writing to a storage slot that's currently zero costs ~20,000 gas the first time but ~5,000 when the slot is already "warm" or non-zero. If state shifts between your estimate and inclusion, the real cost can exceed the estimate — and you get an out-of-gas revert.

Why you add a buffer to the gas limit

Because under-estimating is the failure mode that hurts, you pad the gas limit — commonly by 20–30%. The key insight that makes this safe:

Unused gas is refunded. You pay for gas used, not gas limit. Setting a generous limit costs you nothing if the transaction uses less.

So over-setting the gas limit is cheap insurance against an out-of-gas revert; under-setting it risks a failed transaction where you still pay for the gas burned up to the point it ran out. The only real constraints on a high limit: it must stay under the block gas limit, and your account needs the balance to cover maxFeePerGas × gasLimit up front (the difference is refunded).

// viem — estimate, then buffer the limit by 25%
const estimate = await publicClient.estimateGas({
  account, to, data, value,
});
const gasLimit = (estimate * 125n) / 100n;   // +25% headroom

A note on the opposite mistake: don't buffer blindly to absurd values on a contract you don't trust — a malicious estimateGas target can be crafted to mislead — but for normal transactions, 20–30% is the sane default.

The fee side: building EIP-1559 values

The limit is only half the story. You also set the price, and post-1559 that's two numbers:

Field Meaning Where it comes from
maxPriorityFeePerGas tip to the validator eth_maxPriorityFeePerGas or a eth_feeHistory percentile
maxFeePerGas ceiling on total per-gas price baseFee × ~2 + priorityFee (headroom for base-fee rises)

The base fee itself you read from the latest block (eth_getBlockByNumberbaseFeePerGas), but the better tool is eth_feeHistory, which returns recent base fees and tip percentiles so you can pick a competitive priority fee:

curl -s -X POST https://rpc.swiftnodes.io/rpc/eth?key=YOUR_API_KEY \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","method":"eth_feeHistory",
       "params":["0x5","latest",[10,50,90]],"id":1}'
# reward[] gives the 10th/50th/90th-percentile tips over the last 5 blocks

Both maxFeePerGas and maxPriorityFeePerGas are ceilings, not fixed charges. You actually pay baseFee + min(tip, maxFee − baseFee), and anything up to maxFee that isn't spent is refunded. So — just like the gas limit — setting maxFeePerGas comfortably above the current base fee is safe: it lets your transaction survive a few blocks of rising base fee without overpaying when fees are calm.

The two fee failure modes mirror the gas-limit ones:

  • max fee per gas less than block base fee — your maxFeePerGas is below the current base fee, so the transaction is rejected outright.
  • Stuck pending — your maxFeePerGas clears the base fee but your tip is too low to be attractive, so validators skip it. It sits in the mempool until fees drop or you replace it with a higher tip.

Putting it together

Most libraries will do all of this for you if you let them — but knowing what's under the hood is what lets you override it when you need to. Here's the full picture in viem and ethers:

// viem — the library computes 1559 fees; you buffer the limit
const fees = await publicClient.estimateFeesPerGas();     // maxFeePerGas + maxPriorityFeePerGas
const gas  = await publicClient.estimateGas({ account, to, data, value });

const hash = await walletClient.sendTransaction({
  account, to, data, value,
  gas: (gas * 125n) / 100n,
  maxFeePerGas: fees.maxFeePerGas,
  maxPriorityFeePerGas: fees.maxPriorityFeePerGas,
});
// ethers v6 — getFeeData() returns 1559 fields; estimateGas for the limit
const fee = await provider.getFeeData();                  // maxFeePerGas, maxPriorityFeePerGas
const est = await contract.transfer.estimateGas(to, amount);

const tx = await contract.transfer(to, amount, {
  gasLimit: (est * 125n) / 100n,
  maxFeePerGas: fee.maxFeePerGas,
  maxPriorityFeePerGas: fee.maxPriorityFeePerGas,
});

If you send with no gas or fee fields at all, both libraries call eth_estimateGas + a fee source for you. The reasons to take manual control: you want a bigger safety buffer on a state-heavy contract call, you're batching and want consistent fees, or you're building a replacement transaction and need to clear the ~10% bump rule.

L2s estimate gas differently

If you're on a rollup, don't assume eth_estimateGas captures the whole cost. Most L2s charge an L1 data fee on top of L2 execution gas — the cost of posting your transaction's data to Ethereum — and a plain eth_estimateGas may not reflect it. zkSync Era, for example, has a pubdata component that makes standard estimates unreliable; you use zks_estimateFee instead (see zkSync Era RPC: What's Different). Optimistic rollups expose the L1 portion through a gas-price oracle contract. The two-numbers model still holds — there's just a third, L1-data dimension bolted on.

The short version

Gas is two questions: how much compute (units, from eth_estimateGas) and how much you pay per unit (base fee + tip, EIP-1559). eth_estimateGas simulates at current state, errors if the transaction would revert, and can under-estimate when state changes — so buffer the gas limit 20–30%, which is free because unused gas is refunded. Build fees from eth_feeHistory / estimateFeesPerGas; both maxFeePerGas and maxPriorityFeePerGas are ceilings you won't overpay, so leave headroom above the base fee. Too little gas → out-of-gas revert; too little fee → rejected or stuck. On L2s, add the L1 data fee.

Accurate estimates need an endpoint that reliably serves eth_estimateGas, eth_feeHistory, and eth_maxPriorityFeePerGas against fresh state. A flat-rate Ethereum RPC endpoint gives you all of them across dozens of chains under one key. Grab a free key and point your app at:

https://rpc.swiftnodes.io/rpc/eth?key=YOUR_API_KEY

Related posts

  • Handling Chain Reorgs in Your Indexer

    Your indexer works perfectly in testing, then a reorg silently corrupts your database with transactions that no longer exist. Here's how reorgs break indexers and two proven patterns to handle them — confirmation lag and reorg detection with rollback.

  • 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.

Try SwiftNodes free — multi-chain RPC across 75+ networks, flat-rate pricing, pay by card or crypto, no KYC. Get an API key in 30 seconds →