TechLead
Lesson 15 of 20
6 min read
Web3 & Blockchain

Gas Optimization Techniques

Reduce smart contract gas costs with storage packing, efficient data structures, assembly optimizations, and Solidity best practices

Why Gas Optimization Matters

Every operation on Ethereum costs gas, and users pay for that gas in ETH. A poorly optimized contract can cost users hundreds of dollars per transaction during high network activity. Gas optimization is both an economic necessity (lower costs attract more users) and a technical skill that demonstrates deep understanding of the EVM. The key is understanding what operations are expensive and finding ways to reduce or eliminate them.

EVM Gas Cost Reference

  • SSTORE (new slot): 20,000 gas — writing to a new storage slot is the most expensive common operation
  • SSTORE (update): 5,000 gas — updating an existing non-zero slot
  • SSTORE (zero to zero): 2,900 gas with refund — clearing a slot gives a partial refund
  • SLOAD: 2,100 gas (cold) / 100 gas (warm) — first read is expensive, subsequent reads are cheap
  • MSTORE/MLOAD: 3 gas — memory is very cheap compared to storage
  • CALLDATALOAD: 3 gas — reading function arguments is cheap

Storage Packing

The EVM uses 32-byte (256-bit) storage slots. If your variables are smaller than 32 bytes and declared consecutively, Solidity packs them into the same slot, saving gas on both reads and writes.

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

// BAD: Each variable gets its own 32-byte slot (3 slots = 3 SSTORE operations)
contract Unpacked {
    uint256 public amount;    // Slot 0 (32 bytes)
    bool public isActive;     // Slot 1 (only uses 1 byte, wastes 31)
    address public owner;     // Slot 2 (only uses 20 bytes, wastes 12)
}

// GOOD: Variables are packed into fewer slots (2 slots instead of 3)
contract Packed {
    uint256 public amount;    // Slot 0 (32 bytes — full slot)
    address public owner;     // Slot 1 (20 bytes)
    bool public isActive;     // Slot 1 (1 byte — packed with address!)
    // Total: 2 slots instead of 3
}

// EVEN BETTER: Use smaller uint types when possible
contract OptimalPacking {
    // All fit in ONE slot (20 + 8 + 4 = 32 bytes)
    address public owner;     // 20 bytes
    uint64 public timestamp;  // 8 bytes
    uint32 public amount;     // 4 bytes
}

Use Constants and Immutables

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

contract GasEfficient {
    // constant — value embedded in bytecode, NO storage slot, NO SLOAD
    uint256 public constant MAX_SUPPLY = 10000;        // 0 gas to read
    bytes32 public constant ADMIN_ROLE = keccak256("ADMIN");

    // immutable — set once in constructor, stored in bytecode, NO SLOAD
    address public immutable deployer;                  // 0 gas to read
    uint256 public immutable deployTimestamp;

    // Regular storage variable — costs 2,100 gas (cold) to read
    uint256 public regularVariable;

    constructor() {
        deployer = msg.sender;
        deployTimestamp = block.timestamp;
        regularVariable = 42;
    }
}

Efficient Loops and Caching

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

contract LoopOptimizations {
    uint256[] public values;
    mapping(address => uint256) public balances;

    // BAD: Reads .length from storage on EVERY iteration
    function sumBad() external view returns (uint256 total) {
        for (uint256 i = 0; i < values.length; i++) {
            total += values[i];
        }
    }

    // GOOD: Cache length in memory, use unchecked increment
    function sumGood() external view returns (uint256 total) {
        uint256 len = values.length; // Cache storage read
        for (uint256 i = 0; i < len; ) {
            total += values[i];
            unchecked { ++i; } // Save ~60 gas per iteration (no overflow check)
        }
    }

    // Cache storage variable in memory for multiple reads
    function processBalance(address user) external {
        uint256 bal = balances[user]; // One SLOAD (2,100 gas)

        // Use memory variable for all subsequent reads (3 gas each)
        if (bal > 100) {
            // ... use bal instead of balances[user]
        }
        if (bal > 50) {
            // ... use bal again
        }
    }
}

Custom Errors and Short Revert Strings

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

contract ErrorOptimization {
    // BAD: String messages stored in bytecode, expensive to deploy and revert
    function badRevert(uint256 x) external pure {
        require(x > 0, "Value must be greater than zero"); // ~200+ gas per character
    }

    // GOOD: Custom errors — much cheaper, typed, and can carry data
    error ValueTooLow(uint256 provided, uint256 minimum);

    function goodRevert(uint256 x) external pure {
        if (x == 0) revert ValueTooLow(x, 1); // ~24 gas for the selector
    }
}

Calldata over Memory

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

contract CalldataOptimization {
    // BAD: memory copies the entire array from calldata to memory
    function processBad(uint256[] memory data) external pure returns (uint256) {
        uint256 sum = 0;
        for (uint256 i = 0; i < data.length; i++) {
            sum += data[i];
        }
        return sum;
    }

    // GOOD: calldata reads directly from transaction data (no copy)
    function processGood(uint256[] calldata data) external pure returns (uint256) {
        uint256 sum = 0;
        for (uint256 i = 0; i < data.length; i++) {
            sum += data[i];
        }
        return sum;
    }
    // Savings: ~60 gas per array element (no memory allocation/copy)
}

Mapping vs Array, and Batch Operations

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

contract BatchOperations {
    mapping(address => uint256) public balances;

    // BAD: Multiple separate transactions (21,000 base gas each!)
    function singleTransfer(address to, uint256 amount) external {
        balances[msg.sender] -= amount;
        balances[to] += amount;
    }

    // GOOD: Batch multiple operations in one transaction
    function batchTransfer(
        address[] calldata recipients,
        uint256[] calldata amounts
    ) external {
        require(recipients.length == amounts.length, "Length mismatch");
        uint256 totalAmount = 0;
        uint256 len = recipients.length;

        for (uint256 i = 0; i < len; ) {
            totalAmount += amounts[i];
            balances[recipients[i]] += amounts[i];
            unchecked { ++i; }
        }

        balances[msg.sender] -= totalAmount;
    }
    // Saves 21,000 gas per additional transfer (base transaction cost)
}

Gas Optimization Checklist

  • Pack storage variables — order them by size to minimize slot usage
  • Use constant/immutable — for values that never change after deployment
  • Cache storage reads — read to memory once, use the cached value repeatedly
  • Use calldata for inputs — avoid copying data to memory when you only need to read it
  • Use custom errors — much cheaper than require with string messages
  • Unchecked math — use unchecked blocks when overflow is impossible (e.g., loop counters)
  • Batch operations — combine multiple operations into single transactions
  • Use events for data — emit events instead of storing data that only needs to be read off-chain

Continue Learning