What is Gas and Why Fees Change in EVM

1. Overview

If you’ve ever sent a transaction on Ethereum and seen fees fluctuate tenfold, you’re touching on the concept of gas. This article explains what gas is, how the EVM calculates fees before and after EIP-1559, and why baseFee changes with each block. After reading, you’ll be able to calculate transaction fees yourself with code and know how to set maxFeePerGas reasonably to avoid transactions hanging or overpaying.

2. What is Gas

Gas is a unit that measures the computational work that the EVM (Ethereum Virtual Machine) must perform to execute a transaction. Each opcode (virtual machine instruction) has a fixed gas cost — adding integers (ADD) is cheap, writing data to storage (SSTORE) costs hundreds of times more.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract Counter {
    uint256 public value; // slot storage

    function increment() public {
        value = value + 1; // ADD (~3 gas) + SSTORE (~5000-20000 gas)
    }
}

The line value = value + 1 is actually two steps: arithmetic calculation (cheap) and writing back to storage (expensive). This is why optimizing smart contracts usually focuses on reducing the number of storage reads/writes, not reducing computations.

Note: Gas is not money. Gas is “amount of work”. Real fee = gas used × price per gas (in ETH).

3. Gas limit, gas price, and gas used

Three concepts often confused:

  • Gas limit: the maximum gas you allow the transaction to spend. Set too low → transaction reverts with out of gas error.
  • Gas used: the gas actually spent, always ≤ gas limit.
  • Gas price: the price per unit of gas, denominated in gwei (1 gwei = 10⁻⁹ ETH).
Transaction fee (before EIP-1559) = gasUsed × gasPrice

For example, a simple ETH transfer costs 21,000 gas; if gasPrice = 30 gwei:

Fee = 21,000 × 30 gwei = 630,000 gwei = 0.00063 ETH

4. The Old Mechanism: Legacy Gas Price

Before EIP-1559 (before the London fork in 2021), users set gasPrice themselves. Higher price meant miners prioritized including the transaction in a block sooner. This mechanism was like blind bidding: you didn’t know what others were paying, so you’d easily overpay or underpay and have to wait.

5. EIP-1559: Base Fee + Priority Fee

EIP-1559 splits the fee into two parts:

  • baseFee: calculated by the network based on the fullness of the previous block, and is burned (destroyed, doesn’t go to validators).
  • priorityFee (tip): you pay extra to the validator to be prioritized.

Users set two values:

  • maxFeePerGas: the maximum price per gas you’re willing to pay.
  • maxPriorityFeePerGas: the maximum tip you’re willing to pay.

5.1. Formula to Calculate Actual Fee

effectiveGasPrice = min(maxFeePerGas, baseFee + maxPriorityFeePerGas)
actual fee        = gasUsed × effectiveGasPrice

5.2. Calculate Fee with Java

<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <version>3.26.3</version>
    <scope>test</scope>
</dependency>
package dev.nonla.evm;

import java.math.BigInteger;

public class FeeCalculator {

    public BigInteger effectiveGasPrice(
            BigInteger baseFee,
            BigInteger maxPriorityFeePerGas,
            BigInteger maxFeePerGas) {

        BigInteger desired = baseFee.add(maxPriorityFeePerGas);
        return desired.min(maxFeePerGas); // never exceeds maxFeePerGas
    }

    public BigInteger totalFee(BigInteger gasUsed, BigInteger effectiveGasPrice) {
        return gasUsed.multiply(effectiveGasPrice);
    }
}
  • effectiveGasPrice takes the smaller value between (baseFee + desired tip) and the cap maxFeePerGas you set.
  • totalFee multiplies by the actual gas used to get the ETH you must pay.
package dev.nonla.evm;

import org.junit.jupiter.api.Test;
import java.math.BigInteger;

import static org.assertj.core.api.Assertions.assertThat;

class FeeCalculatorTest {

    private final FeeCalculator calculator = new FeeCalculator();

    @Test
    void whenBaseFeePlusTipBelowMax_thenUseFullTip() {
        BigInteger baseFee = BigInteger.valueOf(20); // gwei
        BigInteger tip = BigInteger.valueOf(2);
        BigInteger maxFee = BigInteger.valueOf(50);

        BigInteger price = calculator.effectiveGasPrice(baseFee, tip, maxFee);

        assertThat(price).isEqualTo(BigInteger.valueOf(22));
    }

    @Test
    void whenBaseFeeRisesAboveMax_thenCapToMaxFeePerGas() {
        BigInteger baseFee = BigInteger.valueOf(60); // rises sharply
        BigInteger tip = BigInteger.valueOf(2);
        BigInteger maxFee = BigInteger.valueOf(50);

        BigInteger price = calculator.effectiveGasPrice(baseFee, tip, maxFee);

        assertThat(price).isEqualTo(maxFee); // capped at 50, doesn't exceed
    }
}

The second test illustrates exactly why your transaction hangs (pending) when the network is congested: baseFee rises above the maxFeePerGas you set, and validators lose incentive to include it because the actual tip received is too low.

6. Why Base Fee Changes with Each Block

baseFee is not fixed — it adjusts based on the fullness of the previous block, using an approximation formula:

baseFee_next = baseFee_current × (1 + 1/8 × (gasUsed - gasTarget) / gasTarget)

Where gasTarget is 50% of the block’s gasLimit (each Ethereum block has a gas limit of around 30 million, target around 15 million).

  • Block fuller than target → baseFee increases up to 12.5% per block.
  • Block emptier than target → baseFee decreases up to 12.5% per block.
package dev.nonla.evm;

import java.math.BigInteger;

public class BaseFeeAdjuster {

    private static final BigInteger EIGHT = BigInteger.valueOf(8);

    public BigInteger nextBaseFee(BigInteger currentBaseFee, long gasUsed, long gasTarget) {
        long delta = gasUsed - gasTarget;
        BigInteger change = currentBaseFee
                .multiply(BigInteger.valueOf(delta))
                .divide(BigInteger.valueOf(gasTarget))
                .divide(EIGHT);

        BigInteger next = currentBaseFee.add(change);
        // baseFee is never negative
        return next.max(BigInteger.ONE);
    }
}
@Test
void whenBlockFullerThanTarget_thenBaseFeesIncreases() {
    BaseFeeAdjuster adjuster = new BaseFeeAdjuster();
    BigInteger current = BigInteger.valueOf(100); // gwei

    BigInteger next = adjuster.nextBaseFee(current, 30_000_000L, 15_000_000L);

    // gasUsed = 2x target -> increases by ~12.5%
    assertThat(next).isGreaterThan(current);
}

This self-adjusting mechanism explains why you see baseFee jumping continuously every ~12 seconds (one block’s time): the network is balancing the supply and demand for block space, not miners/validators setting prices arbitrarily.

7. Observing Base Fee and Gas Price via JSON-RPC

You can get baseFeePerGas directly from an Ethereum node:

curl -s -X POST https://rpc.ankr.com/eth \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","method":"eth_getBlockByNumber","params":["latest",false],"id":1}' \
  | jq '.result.baseFeePerGas'
"0x9502f9000"

The returned value is in hex (wei); convert to gwei by dividing by 10⁹.

8. Estimate Gas Before Sending Transactions with Web3j

Instead of guessing gasLimit, let the node calculate it beforehand with eth_estimateGas — avoid setting too low (revert) or too high (temporarily locking excess ETH).

<dependency>
    <groupId>org.web3j</groupId>
    <artifactId>core</artifactId>
    <version>4.12.2</version>
</dependency>
package dev.nonla.evm;

import org.web3j.protocol.Web3j;
import org.web3j.protocol.core.methods.response.EthEstimateGas;
import org.web3j.protocol.core.methods.request.Transaction;
import java.math.BigInteger;

public class GasEstimator {

    private final Web3j web3j;

    public GasEstimator(Web3j web3j) {
        this.web3j = web3j;
    }

    public BigInteger estimate(String from, String to, String data) throws Exception {
        Transaction tx = Transaction.createFunctionCallTransaction(
                from, null, null, null, to, data);

        EthEstimateGas response = web3j.ethEstimateGas(tx).send();
        if (response.hasError()) {
            throw new IllegalStateException("Estimation failed: " + response.getError().getMessage());
        }
        return response.getAmountUsed();
    }
}
  • ethEstimateGas simulates the transaction on the current state and returns the gas needed.
  • In practice, you should add a 10–20% buffer to this result before setting gasLimit, because the state may change between estimation and actual transaction execution.

9. Common Mistakes

  • Setting gasLimit too close to the estimate: if storage layout changes slightly (e.g., slot from 0 → non-zero costs different gas), the transaction easily reverts with out of gas. Always add a buffer.
  • Setting maxFeePerGas too low during network congestion: the transaction sits in the mempool indefinitely with no automatic timeout — you must cancel it yourself by sending a transaction with the same nonce but higher fee (replace-by-fee).
  • Confusing gasPrice (legacy) with maxFeePerGas (EIP-1559): sending an old-style transaction on a network that supports 1559 still works, but you lose control over the tip separately — you may overpay compared to the actual baseFee.
  • Setting priorityFee too low: if the tip is close to 0 when blocks are full, validators have no incentive to include your transaction early even if maxFeePerGas is high.

Note: Don’t hard-code a fixed gasPrice in production code. Always call eth_feeHistory or eth_gasPrice to get current market prices, then apply the formula from section 5.

10. Conclusion

Gas is a unit measuring computational work in the EVM; actual fee = gas used × price per gas. With EIP-1559, that price splits into baseFee that self-adjusts based on block fullness and priorityFee that you choose for prioritization. Understanding this formula helps you set gasLimit/maxFeePerGas reasonably, avoiding transaction hangs or unnecessary fees — and if needed, you can completely write fee-calculation logic in Java like the example above to integrate into your wallet-tracking backend or transaction dashboard.

evmblockchainethereumgaseip-1559