logo

Calculating and Displaying Gas Fees (EIP-1559): A Practical Guide for Wallet and dApp UIs

A vibrant neon dollar sign illuminated on a computer monitor.

Gas feels simple right up until you have to show it to a user. In a console, “base fee plus tip” is clean.

On a send screen, it turns into a moving target with caps, refunds, and numbers that change while the user is reading them. The first time your UI shows one cost, and the receipt shows another, trust takes a hit. People don’t mind paying fees as much as they mind feeling surprised by them.

EIP-1559 was supposed to make fees more predictable, and in many ways it did. It gave the network a structured base fee and made “overpaying” less common. Yet it also introduced new language that UIs often translate poorly. “Max fee” sounds like a charge, even though it’s usually just a ceiling. A “refund” sounds like a correction, even though it’s normal behavior. The goal of a good fee display isn’t perfect prediction. It’s a clear explanation of what the user is authorizing, what they’re likely to pay, and what can change before the transaction lands.

What EIP-1559 fees are made of

EIP-1559 introduced a fee model that splits what users pay into two different ideas. The network sets a base fee for each block, and that base fee is burned. Users add a priority fee that acts as a tip to encourage faster inclusion. Transactions also include a max fee per gas as a safety cap, which protects the user if base fees rise while the transaction is waiting. Wallet UIs often call that cap “max fee,” and it’s the source of a lot of confusion because it is usually higher than what the user actually ends up paying.

The clean mental model is that the base fee is not something your app negotiates. It changes with demand and follows protocol rules. The priority fee is the dial that your product can recommend and that users can tweak. The max fee is the budget ceiling that keeps the transaction valid as conditions change, and it creates “headroom” that may never be spent.

What you can predict before the transaction lands

A fee UI works when it separates what is knowable now from what is only knowable later. Users don’t need perfect prediction. They need a number that behaves like a promise, plus a clear explanation of why that promise has boundaries.

The hardest constraint is that gas used is final only after execution. Before execution, you work with a gas limit and an estimate. The base fee is also volatile because it can rise or fall from block to block. That means there are two sources of uncertainty in every send screen: how much gas the transaction will actually consume, and what base fee will be at the moment it gets included.

This is why EIP-1559 introduced a cap. If the base fee rises, a transaction can still be included as long as the cap allows it. If the base fee stays lower than the cap, the user doesn’t pay the difference. Many wallets describe the unused part as a refund of headroom, which matches how it feels to the user: they “approved up to X,” and the chain charged less.

Here’s what you can usually predict up front with reasonable confidence, and what you should treat as conditional:

  • Whether the transaction is simple or complex, which affects the gas estimate quality. A plain ETH transfer is predictable, while contract interactions can vary based on the state at execution time.
  • A realistic gas limit range, derived from estimateGas plus a small safety margin. It’s still a ceiling, yet it lets you show a sensible maximum cost.
  • The current base fee level is taken from the latest block. It’s a snapshot, not a guarantee, so it works best as the basis for a “likely” cost if the transaction lands soon.
  • A recommended priority fee tier, based on recent network conditions or wallet/provider defaults. It won’t guarantee inclusion, but it gives users a meaningful speed choice.
  • A safe maximum cost, computed from gas limit and max fee per gas, which is the closest thing you can offer to a promise: the user won’t pay more than this for gas.

How to calculate the fee numbers for a send screen

You generally need two totals: one that is a safe upper bound, and one that is a realistic expectation if the transaction lands soon. Those two totals keep the UI truthful.

Choosing the priority fee

Priority fee is where your UX has leverage. You can expose “slow, market, fast” tiers and map them to a recommended priority fee. If you have access to recent block data, eth_feeHistory can provide effective priority fee samples at percentiles, which is a common way to anchor those tiers in current conditions. Providers document eth_feeHistory as a method designed to return base fee history and priority fee information for estimation.

If you don’t want to tune percentiles early, you can still build a stable baseline by using a node estimate for priority fee and treating it as a default. The main point is consistency: the same tier should produce the same kind of behavior across screens and across retries.

Setting the max fee so transactions don’t stall immediately

A max fee that is too tight creates a specific failure pattern: the transaction sits pending, the user sees no progress, and the only fix is a replacement with higher fees. A max fee that is too high creates a different problem: the “max cost” number looks scary unless you explain how much is typically paid.

Many engineering write-ups recommend adding headroom to the current base fee so the transaction stays valid for a few blocks even if the base fee rises. The exact multiplier is the product policy. What matters is that your policy is stable and that your UI communicates the difference between “allowed maximum” and “likely paid.”

Computing the two totals that users actually care about

A simple, reliable approach is to show:

  • a likely total fee based on the current base fee plus your chosen priority fee, multiplied by the gas estimate or gas limit
  • a maximum total fee based on the max fee per gas multiplied by the gas limit

MetaMask describes the total fee idea in terms of base fee plus priority fee times gas used, and it also explains that unused headroom between the cap and the effective fee is returned to the user. That framing aligns well with a UI that shows both numbers. Users see what is expected and what is authorized.

How to present gas fees without confusing users

Most fee confusion is not about units. It’s about language. EIP-1559 introduced words that sound like outcomes even though they are constraints.

Step 1: Use labels that match what users think they are approving

“Max fee” reads like “you will pay.” In reality, it often means “you permit up to this amount if base fees rise.” If you show only the cap, users believe they were overcharged even when they weren’t. If you show only the likely cost, users get stuck when the base fee rises and don’t understand why they need to speed up.

A practical UI shows a primary number that is the likely cost and a secondary number that is the maximum authorized cost. Then it uses one short sentence to explain why they differ: the cap protects the transaction if network fees rise, and unused headroom isn’t spent. This matches how major wallets explain the refund behavior in plain terms.

Step 2: Keep small fees readable without rounding them into lies

Gas fees can be small in ETH terms and still meaningful. Aggressive rounding turns a real cost into “0.00 ETH,” which looks broken. The fix is a formatting policy that scales with magnitude. For tiny values, show more precision. For larger values, keep the main line clean and move precision into a detail view. This is also where fiat helps. Users anchor on dollars far more reliably than on gwei.

Step 3: Handle pending transactions as part of the fee story

EIP-1559 did not eliminate congestion. Users will still see pending transactions. A good UI treats this as a normal case and keeps the original fee parameters accessible so the “speed up” flow can recommend a reasonable bump. The trap here is raising only the priority fee while leaving the max fee too tight. If the base fee has moved up, the transaction still won’t land. Your bump logic should increase headroom, not just the tip.

How ethers.js and web3.js influence your fee logic

Libraries don’t just provide helpers. They also nudge teams toward certain defaults, and those defaults can quietly become your product’s fee policy. The safest approach is to treat library outputs as raw signals, then apply your own rules consistently across screens and environments.

ethers.js fee fields and transaction typing

ethers.js makes EIP-1559 feel “native” because the transaction object naturally supports maxFeePerGas and maxPriorityFeePerGas, while legacy-style transactions rely on gasPrice. That’s a good guardrail, since it reduces the chance you’ll build a transaction with an incoherent mix of fee fields. It also means your fee module can treat “transaction type” as a first-class decision instead of inferring it from random inputs.

The subtle risk is that different providers and signers can behave differently if you give them incomplete fee fields. If one code path sets only maxFeePerGas and another sets both maxFeePerGas and maxPriorityFeePerGas, you can end up with inconsistent inclusion speeds for the same “fast” tier. A clean module decides the exact fee field set it will use for type-2 transactions and always fills them explicitly before signing.

ethers.js getFeeData and hidden assumptions

ethers.js exposes getFeeData() as a convenient way to retrieve fee suggestions from a provider, and many teams adopt it early because it removes guesswork. The output is usually good enough for MVPs, and it helps you avoid hardcoding stale gas rules. The downside is that it’s an opinionated snapshot from your provider, and you don’t control the assumptions behind it.

If you want a stable UX, treat getFeeData() as an input, not as the answer. Your module should still apply its own headroom rule for max fee, its own tier mapping for priority fee, and its own safety checks for incoherent combinations. That way, switching RPC providers or adding a new chain doesn’t silently change what “fast” means in your UI.

web3.js legacy defaults and mixed-mode pitfalls

web3.js has a long history of snippets that rely on getGasPrice, which maps cleanly to the old “single number” model. That’s fine when you truly operate on a legacy-fee chain or when you intentionally choose to use gasPrice. The issue is that this legacy habit tends to linger even after a product moves to EIP-1559 style transactions.

In mixed-chain apps, this often shows up as a “sticky field” bug: a transaction builder keeps gasPrice in a shared object, then later code adds maxFeePerGas and maxPriorityFeePerGas for an EIP-1559 chain. The result is an ambiguous payload that may serialize differently depending on the tooling, or be rejected outright. Your fee module should act like a gatekeeper here by normalizing fees into exactly one model per transaction and refusing to sign if both models are present.

web3.js EIP-1559 support and consistency rules

web3.js can absolutely work in an EIP-1559-first setup, but it benefits from stricter internal representation. Instead of letting callers pass “whatever fee fields they have,” keep a canonical internal format for dynamic fees and only convert to the exact transaction object shape at the last moment. That prevents UI code and backend code from drifting into incompatible interpretations.

This is also where validation pays off. Before signing, enforce rules like “type-2 transactions must not carry gasPrice,” “max fee must not be below priority fee,” and “gas limit must be present.” web3.js won’t always save you from inconsistent input. A dedicated module that always produces complete, coherent fee fields will.

Benefits of getting EIP-1559 fees right

Fewer failed or stuck transactions during fee spikes

When you set max fees with sensible headroom and recommend priority fees based on current conditions, transactions are less likely to become underpriced while they wait. This reduces the number of replacement transactions you have to guide users through, which is one of the most stressful parts of wallet UX.

Less user anxiety around caps and refunds

EIP-1559 caps look scary when presented as “the cost.” When your UI separates likely cost from maximum authorized cost, users stop treating refunds as surprises and start treating caps as protection. Wallet documentation that explains refunds clearly has trained users to expect this behavior, so aligning with that model reduces confusion.

Cleaner internal logic for multi-chain products

Even if your app supports legacy-fee chains, a clear EIP-1559 fee module makes your transaction builder more predictable. You stop mixing fee fields, stop guessing which type a transaction “should be,” and get clearer telemetry when something fails. Over time, that predictability matters more than the exact estimation method you started with.

Conclusion

EIP-1559 fees become manageable when you treat them as two things at once: a live market signal and a user-facing promise. The base fee and priority fee explain what will likely be paid soon. Max fee explains what the user authorizes if conditions move. Wallets like MetaMask emphasize that unused headroom isn’t spent, which is the key concept your UI needs to convey.

If your product consistently calculates a realistic “likely” number, consistently shows a clear cap, and consistently avoids mixed fee fields, gas stops feeling like a black box. It becomes a predictable part of the transaction flow, even on days when the network is anything but predictable.