Skip to main content
5 min read R.S.

Gas Optimization for Smart Contracts — Practical Tips

Gas is the currency of execution on EVM chains. Every unnecessary operation costs your users money. In this post, we show concrete optimizations with before/after comparisons. All gas values were measured with forge test --gas-report on Solidity 0.8.24.

1. Storage vs. Memory

An SSTORE (writing to storage) costs 20,000 gas for a new slot or 5,000 gas for an update. An MSTORE (writing to memory) costs 3 gas. Cache storage variables in local variables when you access them multiple times:

Before (3 storage reads):

function calculateReward(address user) public view returns (uint256) {
    // Each access to balances[user] is an SLOAD (2,100 gas)
    if (balances[user] > threshold) {
        return balances[user] * rewardRate / 10000;
    }
    return balances[user] / 2;
}
// Gas: 6,847

After (1 storage read):

function calculateReward(address user) public view returns (uint256) {
    uint256 bal = balances[user]; // 1x SLOAD
    if (bal > threshold) {
        return bal * rewardRate / 10000;
    }
    return bal / 2;
}
// Gas: 2,891 (-57.8%)

2. Struct Packing

The EVM operates on 32-byte slots. Variables that fit together in one slot are read in a single SLOAD. The order of struct fields determines how many slots are occupied:

Before (3 slots = 3 x 32 bytes):

struct UserInfo {
    uint256 balance;    // Slot 0 (32 bytes)
    bool isActive;      // Slot 1 (1 byte, but occupies full slot)
    uint256 lastClaim;  // Slot 2 (32 bytes)
}
// Deployment: 142,308 gas
// setUser(): 67,411 gas

After (2 slots):

struct UserInfo {
    uint256 balance;     // Slot 0
    uint128 lastClaim;   // Slot 1 (16 bytes) — uint128 suffices until year 10^19
    bool isActive;       // Slot 1 (1 byte, packs into same slot)
}
// Deployment: 118,592 gas (-16.7%)
// setUser(): 45,223 gas (-32.9%)

3. calldata vs. memory

For external functions that only read (not modify) array or struct parameters, use calldata instead of memory. memory copies the data; calldata reads directly from the transaction data.

// Before: 35,124 gas
function processIds(uint256[] memory ids) external {
    for (uint256 i = 0; i < ids.length; i++) {
        emit Processed(ids[i]);
    }
}

// After: 34,201 gas (-2.6%)
function processIds(uint256[] calldata ids) external {
    for (uint256 i = 0; i < ids.length; i++) {
        emit Processed(ids[i]);
    }
}

Savings increase with array size. At 100 elements, the savings are already around 8%.

4. unchecked Blocks for Loop Counters

Since Solidity 0.8, the compiler checks for overflow on every arithmetic operation. For loop counters that provably cannot overflow, unchecked saves gas:

// Before: 48,312 gas (100 iterations)
for (uint256 i = 0; i < 100; i++) {
    total += values[i];
}

// After: 43,879 gas (-9.2%)
for (uint256 i = 0; i < 100; ) {
    total += values[i];
    unchecked { ++i; }
}

Note: ++i (prefix) is marginally cheaper than i++ (postfix), since no temporary value is stored.

5. Events Instead of Storage for Historical Data

When data only needs to be read off-chain (e.g., for a frontend), events are drastically cheaper than storage:

// Before: storage array — 20,000+ gas per entry
mapping(address => uint256[]) public userHistory;

function recordAction(uint256 actionId) external {
    userHistory[msg.sender].push(actionId);
}

// After: event — 375 gas base + 375 gas per indexed topic
event ActionRecorded(address indexed user, uint256 actionId);

function recordAction(uint256 actionId) external {
    emit ActionRecorded(msg.sender, actionId);
}

Savings: > 95%. Events are stored in the transaction log and queryable via eth_getLogs, but not readable by other contracts.

6. Constants and Immutables

// Variable: 2,100 gas (SLOAD)
uint256 public fee = 250;

// constant: 0 gas (inlined at compile time)
uint256 public constant FEE = 250;

// immutable: 0 gas at runtime (embedded in bytecode)
uint256 public immutable deploymentTime;

constructor() {
    deploymentTime = block.timestamp;
}

Use constant for values known at compile time, and immutable for values set in the constructor.

Summary

Optimization               | Typical savings
---------------------------|----------------
Cache storage reads        | 40-60%
Struct packing             | 15-35%
calldata over memory       | 2-8%
unchecked loop counters    | 8-12%
Events over storage        | > 95%
constant/immutable         | 100% (no SLOAD)

Each individual optimization may seem small. In a contract with 20 functions and thousands of daily calls, they add up to significant savings for your users. Always measure with forge test --gas-report — do not optimize based on assumptions.