Gas costs money. On Ethereum mainnet, a complex transaction can cost $50-200. Here's how to cut that in half.
Understand What Costs Gas
SSTORE (storage write): 20,000 gas (cold), 2,900 gas (warm)
SLOAD (storage read): 2,100 gas (cold), 100 gas (warm)
MSTORE (memory write): 3 gas + expansion
CALLDATALOAD: 3 gas
ADD, MUL, etc: 3-8 gas
Storage operations dominate gas costs. Minimize them.
1. Pack Struct Variables
EVM storage uses 32-byte slots. Pack multiple small variables into one slot:
// Bad: 3 separate storage slots = 3x SLOAD/SSTORE cost
struct BadUser {
uint256 id; // slot 0 (32 bytes)
bool active; // slot 1 (32 bytes wasted on 1 bit)
uint256 balance; // slot 2 (32 bytes)
}
// Good: 2 storage slots
struct GoodUser {
uint256 id; // slot 0
uint128 balance; // slot 1 (16 bytes)
uint64 lastSeen; // slot 1 (8 bytes, shares with balance)
bool active; // slot 1 (1 byte, shares with above)
}
Order matters: group smaller types together so they fit in the same slot.
2. Use calldata Instead of memory
// Bad: copies array to memory, costs gas
function processItems(uint256[] memory items) external {
for (uint i = 0; i < items.length; i++) { /* ... */ }
}
// Good: reads directly from calldata, much cheaper
function processItems(uint256[] calldata items) external {
for (uint i = 0; i < items.length; i++) { /* ... */ }
}
Use calldata for function parameters you don't modify. Use memory only when you need to modify the data.
3. Cache Storage Variables
// Bad: 3 SLOAD operations
function bad() external {
for (uint i = 0; i < arr.length; i++) {
total += arr[i]; // arr.length SLOAD each iteration!
}
}
// Good: 1 SLOAD
function good() external {
uint256[] memory _arr = arr; // single SLOAD
uint256 len = _arr.length; // cache length
uint256 _total = 0; // local variable (memory)
for (uint i = 0; i < len; i++) {
_total += _arr[i];
}
total = _total; // single SSTORE at the end
}
4. Use Custom Errors Instead of Strings
// Bad: stores and returns the string on revert (expensive)
require(msg.sender == owner, "Ownable: caller is not the owner");
// Good: just a 4-byte selector on revert
error NotOwner();
if (msg.sender != owner) revert NotOwner();
Custom errors save ~50 gas per revert compared to string errors.
5. Short-Circuit Expensive Operations
// Cheaper check first (avoid SLOAD if msg.value check fails)
function deposit() external payable {
require(msg.value >= MIN_DEPOSIT, "Too small"); // cheap
require(whitelisted[msg.sender], "Not whitelisted"); // expensive SLOAD
}
6. Avoid Redundant Storage Writes
// Wasteful: writing same value back
function update(uint256 newValue) external {
if (storedValue != newValue) { // Check before write
storedValue = newValue; // Only write if changed
}
}
SSTORE to a non-zero value costs 5,000 gas. SSTORE to zero costs 2,900 but gets a 4,800 gas refund. SSTORE of the same value costs only 100 gas (warm slot, no change).
7. Mappings vs Arrays
// Array: O(n) deletion, re-indexing needed
uint256[] public values;
// Mapping: O(1) lookup and deletion
mapping(uint256 => bool) public hasValue;
For existence checks, use a mapping. Arrays only when you need iteration.
8. Use Immutable and Constant
// constant: computed at compile time, 0 gas to read
uint256 constant MAX_SUPPLY = 10_000;
// immutable: set once in constructor, reads cost ~200 gas less than storage reads
address immutable owner;
constructor() {
owner = msg.sender; // Set once, then cheap to read
}
9. Optimizer Settings
// hardhat.config.js
module.exports = {
solidity: {
version: "0.8.24",
settings: {
optimizer: {
enabled: true,
runs: 200 // Higher = optimize for repeated calls, larger bytecode
// Lower = optimize for one-time deployment cost
}
}
}
}
Use runs: 200 for contracts called frequently. Use runs: 1 for factory contracts deployed rarely.
Measure Everything
# Hardhat gas reporter
npm install hardhat-gas-reporter
# In hardhat.config.js
gasReporter: { enabled: true, currency: 'USD' }
Never optimize without measuring first. Profile with gas reporter, optimize the highest-cost functions, and verify savings with before/after tests.