w3-kitdocs
evm5 min readPetarStoev02PetarStoev02

Gas Explained — EVM Fee Mechanics From First Principles

Gas is the unit of computational work on EVM-compatible blockchains. It is not ETH — it is a measure of how much work a transaction requires. You pay for that work in ETH (or the native token of the chain).


What is gas?

Every operation the EVM executes costs a fixed number of gas units:

OpcodeGas costWhat it does
ADD3Integer addition
MUL5Integer multiplication
SLOAD2,100Read a storage slot (cold)
SSTORE20,000Write to a new storage slot
CALL2,600Call another contract (cold)
Base tx cost21,000Minimum cost for any transaction

A simple ETH transfer costs exactly 21,000 gas. A complex DeFi swap might cost 150,000–300,000 gas. A gas unit has no monetary value by itself — the fee depends on the current gas price.


Legacy gas pricing (pre-EIP-1559)

Before the London fork (August 2021), every transaction specified a single gasPrice:

fee = gasPrice × gasUsed

Users bid against each other. Miners included the highest-paying transactions first. During congestion, users had to guess the right gasPrice — too low meant your transaction got stuck for hours.


EIP-1559: the current model

EIP-1559 replaced the single gasPrice with a two-component model:

fee = (baseFeePerGas + maxPriorityFeePerGas) × gasUsed

baseFeePerGas — set by the protocol, not by the user. It adjusts automatically: increases when the previous block was more than 50% full, decreases when it was less than 50% full. It is burned (removed from supply).

maxPriorityFeePerGas — the tip you offer to validators. Goes directly to the validator who includes your transaction. Typical values: 0.1–2 gwei on mainnet.

maxFeePerGas — the absolute maximum per gas unit you are willing to pay. The actual fee will never exceed this. Any unused headroom is refunded:

refund = (maxFeePerGas - baseFeePerGas - priorityFee) × gasUsed

Reading the base fee in Solidity

// Available since Solidity 0.8.7
uint256 currentBaseFee = block.basefee;

Estimating fees with viem

import { createPublicClient, http } from "viem";
import { mainnet } from "viem/chains";

const client = createPublicClient({ chain: mainnet, transport: http() });

// Returns baseFeePerGas, maxFeePerGas, maxPriorityFeePerGas
const fees = await client.estimateFeesPerGas();

// Estimate gas for a specific call
const gas = await client.estimateGas({
  account: "0xYourAddress",
  to: "0xContractAddress",
  data: "0x...",
});

console.log({
  baseFee: fees.baseFeePerGas, // bigint (wei)
  maxFee: fees.maxFeePerGas, // baseFee * 2 + priorityFee (viem default)
  priorityFee: fees.maxPriorityFeePerGas,
  gasUnits: gas,
  estimatedCostWei: fees.maxFeePerGas * gas,
});

Sending a transaction with explicit fee caps

const hash = await walletClient.sendTransaction({
  to: "0xRecipient",
  value: parseEther("0.01"),
  maxFeePerGas: parseGwei("20"), // your hard cap
  maxPriorityFeePerGas: parseGwei("1"), // validator tip
  gas: 21000n,
});

Gas limit

The gas field on a transaction is the limit — the maximum gas units the sender allows this transaction to consume. If execution requires more gas than the limit:

  • The transaction reverts (state changes rolled back)
  • The fee is still charged (validators were paid for the work done)
  • You lose gasLimit × gasPrice worth of ETH

Set the gas limit too high and you waste nothing — unused gas is not charged. Setting it too low causes a failed transaction. Always use estimateGas before sending.


Block gas limit

The protocol sets a per-block gas limit (~30 million gas on Ethereum mainnet). This caps how many transactions fit in one block. The base fee adjusts to keep blocks roughly 50% full on average (the "target" is 15M gas per block).


Gas on L2s

Layer 2 networks use gas too, but the cost structure has two components:

Optimistic Rollups (Optimism, Arbitrum, Base):

total fee = L2 execution fee + L1 data fee

The L1 data fee covers posting your transaction's calldata to Ethereum mainnet as a blob. Compressing calldata reduces this fee significantly.

ZK Rollups (zkSync, Starknet, Polygon zkEVM):

total fee = L2 execution fee + proof amortization cost

The proof cost is shared across all transactions in a batch. In practice, L2 fees are 10–100x cheaper than mainnet.


Optimization tips

Pack storage variables. The EVM storage slot is 32 bytes. Multiple smaller variables can share one slot:

// Bad: 3 separate 32-byte slots (60,000 gas for 3 SSTOREs)
uint256 a;
uint256 b;
uint256 c;

// Good: all packed into one 32-byte slot (20,000 gas for 1 SSTORE)
uint128 a;
uint64  b;
uint64  c;

Use calldata instead of memory for read-only args. calldata is cheaper because it is not copied:

// Expensive: copies array into memory
function process(uint256[] memory ids) external { ... }

// Cheaper: reads directly from calldata
function process(uint256[] calldata ids) external { ... }

Use unchecked {} for math that cannot overflow:

// Overflow check costs ~3 extra gas per operation
for (uint256 i = 0; i < length; ) {
    // do work
    unchecked { ++i; } // safe: i < length guarantees no overflow
}

Avoid on-chain storage when events suffice. Emitting an event costs ~375 gas + 8 gas per non-zero byte. Writing to storage costs 20,000 gas. If you only need historical records (not on-chain reads), use events.

Batch operations. Every transaction has a fixed 21,000 gas base cost. Batching 10 operations into one transaction saves 189,000 gas in base costs alone.


Further reading